diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 4c8a6933a8..a4106b4fd0 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -204,6 +204,109 @@ retain replayable request state and expose it through an attach/snapshot path. A one-shot frontend event plus an unresolved channel is not a complete multi-device implementation. +### Sidebar device catalog + +The session list shows every online host of the account in place, grouped by +device in a fixed order (this machine first, then peers by name). The rendered +device's section is its live workspace container. Every other section, this +machine included while a peer is rendered, is drawn from a read-only catalog +(`infrastructure/peer-device/deviceWorkspaceCatalog.ts`): + +- Every online, compatible host's catalog is kept current while the sidebar + lists it, including the first session page of each of its workspaces + (bounded to two concurrent reads per device). That is what lets device + headers, session rows and the Agent companion show running, waiting and + unread sessions of every device at once. Desktop hosts do not fan + `agentic://` events out to controllers, so the host's catalog summary is the + freshest status for a background device; observed events only fill sessions + the catalog has no summary for. +- A peer is read through a raw `host_invoke` device RPC limited to + `peer_mode_ping`, `get_opened_workspaces` and `list_persisted_sessions_page` + (`deviceCatalogTransport.ts`). It needs no control attachment and does not + fan out events. This machine is read through its own Tauri transport. Session + reads use `workspace_id` only when the host advertises + `workspace_id_references_v1`; older hosts get their own root path. +- Freshness comes from invalidations, not polling. While a peer section is open + the controller subscribes the peer's `@host/catalog` stream. Its + `host-catalog-changed` hint is surface-scoped, so only the catalog observer + sees it. The catalog also observes local and attached-peer lifecycle events. + Hints are debounced (300 ms) and coalesced into one in-flight read per device. + A hint whose `workspacesRevision` did not move refreshes session pages only; + refreshes are held while the window is hidden or a cross-device open runs and + run once afterwards; the device that just became rendered is not re-read; + catalog notifications are coalesced per task. + A host without the stream is re-read when the window becomes visible and + every 60 s while it is visible. The stream is released 30 s after the section + closes. A newer session read of a workspace replaces an older one in flight, + and a host that refuses a read is asked again how it addresses sessions. +- A failed read keeps the last rendered catalog and offers a retry. An + incompatible build is listed but not read. + +Navigation draws a background device from its own FlowChat container, seeded +from the catalog through the same metadata-to-Session projection a metadata +page uses (`flowChatStore.seedSurfaceSessions`; never the rendered surface). +Rows use the live list's hierarchy, comparator, view filters, first-level +limit and project disclosure (window-wide, keyed by surface), so a device looks +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. + +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 +compete with the open), catalog refreshes are held and run once afterwards, the +session's tab is held in front (`sceneStore.holdSessionScene`) from the click through both switch resets and session-tab reconciliation, +and the main area shows the session's open overlay from the click on. Low-priority peer +requests (git, search, file tree, watchers, mini apps) queued during the open +wait in the adapter and run once it settles; session-status reconciliation +waits likewise (held for at most 8 s, so a slow host cannot starve them), and +`set_active_workspace` is high priority. + +The open is ordered by what the user waits for. On click, the session's host +stream is subscribed directly (`prewarmRelaySessionHistory`; the +`account_subscribe_session` relay command does not depend on the rendered +surface), so the host reads the session while the switch runs. The session's +history owner adopts that stream and its buffered events once the device is +rendered; an unadopted stream closes after 30 s. The switch does not wait for +the device's config while an open runs: it reloads in the background (the +config cache is already scoped to the surface epoch). A host that refuses +device-control status is not polled again on that surface. + +The one write the catalog sends without rendering a device is "mark all +read": `save_session_metadata` limited to `unreadCompletion` and +`needsUserAttention`, applied optimistically and followed by a session-page +refresh. It touches no workspace, tool or permission. + +Acting on a catalog entry (open a session or assistant, new session) first +renders its device through `switchToDevice` / `switchToLocal`. It then runs the +local handler on the now-rendered surface. Every mutation therefore keeps +Peer Device Mode's ownership, capability gates, permission mailboxes and surface +fences. The catalog never mutates a host. The create dialog does the same: it +renders the chosen device before opening the project form, so folder picking, +recent workspaces and sources belong to that device. The personal assistant is +always created on this machine. + +The Agent companion lists background-device sessions next to the rendered +device's, labelled with their device. Opening one renders its device first; +replying from the companion is offered only for the rendered device. A switch +reloads the target's config and workspace catalog concurrently, unless a +cross-device open defers the config. + +### Session tabs across devices + +The top bar keeps one tab per session (`getSessionSceneTabId` keys surface, +workspace and session), so several sessions of one workspace and sessions of +other devices each have their own tab; past 12 session tabs the least recently +used idle one closes. Session tabs of every device survive a switch. +Reconciliation retires only tabs of the rendered device whose session is gone. +When the bar holds tabs of more than one device, it shows each device's tabs +side by side and names the device once, ahead of its group (tabs without a +session belong to the rendered device); a bar of one device names none. A tab +of another device is never selected without its device: activating it runs the same cross-device open as a sidebar row, a +pane never falls back onto it, and an open that fails or is superseded leaves +it in the bar, unselected. Returning to a device reselects the kept tab of its +active session. + ## Cloud account sync vs Peer Remote | Concern | Account cloud sync | Peer Device Mode | diff --git a/src/apps/cli/src/daemon/control.rs b/src/apps/cli/src/daemon/control.rs index 0060813b21..92127c7cbb 100644 --- a/src/apps/cli/src/daemon/control.rs +++ b/src/apps/cli/src/daemon/control.rs @@ -445,7 +445,19 @@ mod tests { "A replacement daemon must wait for bot and account cleanup, even after discovery is withdrawn" ); drop(cleanup_owner); - assert!(Server::reserve_in(root.path().to_path_buf()).await.is_ok()); + // Other tests in this binary spawn processes. A child forked while the + // lock file is open shares its open file description until it execs, + // so the release can land a moment after the last lease is dropped. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match Server::reserve_in(root.path().to_path_buf()).await { + Ok(_) => break, + Err(error) if std::time::Instant::now() >= deadline => { + panic!("The owner lock must be free once every lease is dropped: {error:#}") + } + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(20)).await, + } + } } #[tokio::test] diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index d03b63a801..e3466d012f 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -179,6 +179,7 @@ pub(crate) fn dispatch<'a>( "ensure_coordinator_session" => Box::pin(session::ensure_coordinator_session(state, args)), "get_available_modes" => Box::pin(session::get_available_modes(state, args)), "get_session_stats" => Box::pin(session::get_session_stats(state, args)), + "get_session_usage_report" => Box::pin(session::get_session_usage_report(state, args)), "save_session_turn" => Box::pin(session::save_session_turn(state, args)), // Snapshot / rollback @@ -362,6 +363,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "get_recent_workspaces", "get_session_files", "get_session_stats", + "get_session_usage_report", "get_session_thread_goal", "get_system_info", "get_token_usage_statistics", diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index 62ae361af5..da8ace81e2 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -17,8 +17,8 @@ use openbitfun_events::{project_agentic_frontend_event, AgenticEvent}; use openbitfun_product_domains::product_search::SessionContentSearchRequest; use openbitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionModeUpdateRequest, AgentSessionRenameRequest, AgentThreadGoalGetRequest, - SessionStoragePathRequest, SessionTurnWindowRequest, + AgentSessionModeUpdateRequest, AgentSessionRenameRequest, AgentSessionUsageRequest, + AgentThreadGoalGetRequest, SessionStoragePathRequest, SessionTurnWindowRequest, }; use crate::diagnostics::{OUTCOME_UNKNOWN_ERROR_CODE, SESSION_IN_USE_ERROR_CODE}; @@ -1056,6 +1056,71 @@ pub(crate) async fn get_session_stats( })) } +/// Builds the session usage / context-breakdown report with the same shared +/// runtime port the Desktop command uses, so a controller reading this host's +/// session gets an identical `SessionUsageReport` DTO. Read-only: the report +/// owner opens its own consistent persisted-session view. +pub(crate) async fn get_session_usage_report( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let session_id = validated_session_id(request)?; + let scope = session_storage_request(state, request).await?; + let options = usage_report_options(request)?; + let path_label = scope.workspace_path.to_string_lossy().into_owned(); + let mut report = state + .agent_runtime + .generate_session_usage(AgentSessionUsageRequest { + workspace_id: optional_string(request, "workspaceId"), + session_id, + workspace_path: Some(path_label.clone()), + remote_connection_id: scope.remote_connection_id.clone(), + remote_ssh_host: scope.remote_ssh_host.clone(), + include_hidden_subagents: options.include_hidden_subagents, + include_system_prompt_preview: options.include_system_prompt_preview, + context_preview_category: options.context_preview_category, + }) + .await + .map_err(|error| { + format!( + "Failed to generate session usage report: {}", + error.into_message() + ) + })?; + report.workspace.path_label = Some(path_label); + report.workspace.remote_connection_id = scope.remote_connection_id; + report.workspace.remote_ssh_host = scope.remote_ssh_host; + serde_json::to_value(report).map_err(|error| format!("serialize usage report: {error}")) +} + +#[derive(Debug, PartialEq, Eq)] +struct UsageReportOptions { + include_hidden_subagents: bool, + include_system_prompt_preview: bool, + context_preview_category: Option, +} + +/// Reads the Desktop `GetSessionUsageReportRequest` option fields with the +/// same defaults (hidden subagents included, no prompt preview). Accepts both +/// the snake_case Tauri shape and camelCase, and ignores absent/null fields so +/// older controllers keep working. +fn usage_report_options(request: &Value) -> Result { + let context_preview_category = ["contextPreviewCategory", "context_preview_category"] + .iter() + .find_map(|key| request.get(*key)) + .filter(|value| !value.is_null()) + .map(|value| serde_json::from_value(value.clone())) + .transpose() + .map_err(|error| format!("Invalid context preview category: {error}"))?; + Ok(UsageReportOptions { + include_hidden_subagents: optional_bool(request, "includeHiddenSubagents").unwrap_or(true), + include_system_prompt_preview: optional_bool(request, "includeSystemPromptPreview") + .unwrap_or(false), + context_preview_category, + }) +} + fn session_stats_validation_error(error: impl std::fmt::Display) -> String { format!("Failed to get session stats: Validation error: {error}") } @@ -1103,6 +1168,42 @@ pub(crate) async fn save_session_turn( #[cfg(test)] mod tests { + #[test] + fn usage_report_options_match_desktop_defaults_and_legacy_payloads() { + use openbitfun_core_types::UsageContextCategory; + use serde_json::json; + assert_eq!( + super::usage_report_options(&json!({ "session_id": "s" })).unwrap(), + super::UsageReportOptions { + include_hidden_subagents: true, + include_system_prompt_preview: false, + context_preview_category: None, + } + ); + let snake = json!({ + "include_hidden_subagents": false, + "include_system_prompt_preview": true, + "context_preview_category": "toolDefinitions", + }); + assert_eq!( + super::usage_report_options(&snake).unwrap(), + super::UsageReportOptions { + include_hidden_subagents: false, + include_system_prompt_preview: true, + context_preview_category: Some(UsageContextCategory::ToolDefinitions), + } + ); + // A category a newer controller knows maps to Unknown instead of failing. + let future = json!({ "contextPreviewCategory": "somethingNew" }); + assert_eq!( + super::usage_report_options(&future) + .unwrap() + .context_preview_category, + Some(UsageContextCategory::Unknown) + ); + assert!(super::usage_report_options(&json!({ "context_preview_category": 3 })).is_err()); + } + use super::{ overlay_live_session_state, peer_core_session_error, peer_runtime_session_error, restored_session_to_json, runtime_event_snapshot_to_json, session_stats_validation_error, diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index eae16a178f..0337d66793 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1471,9 +1471,16 @@ impl WorkspaceManager { } fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option { - let same_kind = self - .opened_workspace_ids + // The opened list keeps the user's arrangement, so recency comes from + // the recent lists: the most recently used opened workspace wins. + let mut by_recency = self + .recent_workspaces .iter() + .chain(self.recent_assistant_workspaces.iter()) + .filter(|id| self.opened_workspace_ids.contains(id)) + .chain(self.opened_workspace_ids.iter()); + let same_kind = by_recency + .clone() .find(|id| { self.workspaces .get(id.as_str()) @@ -1493,12 +1500,20 @@ impl WorkspaceManager { return None; } - self.opened_workspace_ids.first().cloned() + by_recency.next().cloned() } - /// Ensures a workspace stays in the opened list. + /// Ensures a workspace is in the opened list. A newly opened workspace goes + /// first; an already opened one keeps its place, so activating a workspace + /// never reorders the list users arrange (recency lives in the recent list). fn ensure_workspace_open(&mut self, workspace_id: &str) { - self.opened_workspace_ids.retain(|id| id != workspace_id); + if self + .opened_workspace_ids + .iter() + .any(|id| id == workspace_id) + { + return; + } self.opened_workspace_ids .insert(0, workspace_id.to_string()); } @@ -1693,6 +1708,49 @@ mod tests { ); } + #[test] + fn activating_an_opened_workspace_keeps_the_opened_order() { + let mut manager = super::WorkspaceManager::new(super::WorkspaceManagerConfig::default()); + manager.opened_workspace_ids = vec!["a".into(), "b".into(), "c".into()]; + manager.ensure_workspace_open("c"); + assert_eq!(manager.opened_workspace_ids, vec!["a", "b", "c"]); + manager.ensure_workspace_open("d"); + assert_eq!(manager.opened_workspace_ids, vec!["d", "a", "b", "c"]); + } + + #[tokio::test] + async fn closing_the_current_workspace_activates_the_most_recently_used_one() { + use super::{WorkspaceInfo, WorkspaceInfoRuntimeExt, WorkspaceKind, WorkspaceOpenOptions}; + + let mut manager = super::WorkspaceManager::new(super::WorkspaceManagerConfig::default()); + let options = WorkspaceOpenOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("ssh-recent".into()), + remote_ssh_host: Some("remote.example".into()), + auto_set_current: false, + ..Default::default() + }; + let mut ids = Vec::new(); + for root in ["/srv/a", "/srv/b", "/srv/c"] { + let workspace = WorkspaceInfo::new_without_worktree(root.into(), options.clone()) + .await + .unwrap(); + ids.push(workspace.id.clone()); + manager.workspaces.insert(workspace.id.clone(), workspace); + } + manager.opened_workspace_ids = ids.clone(); + manager.recent_workspaces = vec![ids[2].clone(), "closed".into(), ids[0].clone()]; + assert_eq!( + manager.find_next_workspace_id_after_close(&WorkspaceKind::Remote), + Some(ids[2].clone()) + ); + manager.recent_workspaces.clear(); + assert_eq!( + manager.find_next_workspace_id_after_close(&WorkspaceKind::Remote), + Some(ids[0].clone()) + ); + } + #[tokio::test] async fn port_scoped_connections_open_distinct_workspaces_beside_legacy_records() { use super::{ diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index cd53c46de0..b97d0cc028 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:ad57aa872484f2a8", + "digest": "fnv1a64:ff25ac8314514c25", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -3377,8 +3377,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index bf02fe319d..d1eea3890d 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -370,7 +370,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_session_stats", Unaudited, Proxied, HANDLED), op("get_session_thread_goal", Unaudited, Proxied, HANDLED), op("get_session_turns", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("get_session_usage_report", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("get_session_usage_report", Unaudited, Proxied, HANDLED), op("get_skill_configs", Unaudited, Proxied, HANDLED), op("get_snapshot_sessions", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_snapshot_system_stats", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 7a227b1373..1ce4f32bb0 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -42,6 +42,9 @@ slices that are outside pure product logic but still platform-neutral. Only the requested page's bodies enter the bounded log; the pending source batch is at most one persisted turn, which can itself be large. No wire or persisted-format migration is required; all controllers retain `read_stream`. + `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. - 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.rs b/src/crates/services/services-integrations/src/remote_connect/host_stream.rs index e2f500078b..acf19362e8 100644 --- a/src/crates/services/services-integrations/src/remote_connect/host_stream.rs +++ b/src/crates/services/services-integrations/src/remote_connect/host_stream.rs @@ -44,6 +44,8 @@ pub struct StreamEvent { /// One `read_stream` answer. `cursor` is the newest sequence the host holds; /// forward reads continue after it, history reads continue before `events[0]`. +/// A history page starts on a turn boundary whenever its bounds allow, so it can +/// hold more than the requested `limit` events; see `StreamLog::backward_page`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct StreamPage { pub stream_id: String, @@ -108,6 +110,28 @@ struct HistoryReader { newer_evicted: bool, } +/// The extent of one history page, see `StreamLog::backward_page`. +struct BackwardPage { + /// Oldest sequence of the page; `None` when nothing lies before the request. + start: Option, + /// Older events could not change this page, so none need materializing. + settled: bool, +} + +/// The turn a session record belongs to, and whether it is that turn's header. +/// Control events and tombstones name no turn. +fn record_turn(event: &StreamEvent) -> Option<(&str, bool)> { + if event.event != "session-record" { + return None; + } + let turn = event.payload["turn"]["turnId"].as_str()?; + let is_header = event.payload["id"] + .as_str() + .and_then(|id| id.strip_prefix("turn/")) + == Some(turn); + Some((turn, is_header)) +} + struct StreamLog { history: Option, epoch: u64, @@ -221,6 +245,71 @@ impl StreamLog { self.events.keys().next().copied().unwrap_or(self.next_seq) } + /// Measure a history page ending just before `before`. `limit` is a budget: + /// once spent, the page keeps growing to the first record of the turn it + /// stopped in, because a turn split across pages is rebuilt in place when the + /// older page arrives, which moves the transcript under its reader. + /// + /// `MAX_PAGE_EVENTS` and `PAGE_BYTES` stay hard bounds. A turn that cannot fit + /// gives way to the newer whole turns of the page; only a page holding no turn + /// header at all (one enormous turn) stays partial, and always makes progress. + /// + /// Headers lead their turn in backfill. A live status change republishes only + /// the header, above its unchanged items; when the budget runs out on such a + /// header the page ends there and those items arrive with the older page. + /// Sizes are inspected by reference: `read_history` calls this once per + /// materialized record, and cloning here would make a read quadratic in bytes. + fn backward_page(&self, before: u64, limit: usize) -> BackwardPage { + let mut start = None; + let mut count = 0; + let mut bytes = 0; + let mut oldest_turn = None; + let mut oldest_is_header = false; + let mut oldest_header = None; + let mut bounded = false; + let mut other_turn = false; + for (&seq, (event, size)) in self.events.range(..before).rev() { + if count > 0 && (count >= MAX_PAGE_EVENTS || bytes + size > PAGE_BYTES) { + bounded = true; + break; + } + let record = record_turn(event); + if count >= limit { + other_turn = match record { + Some((turn, _)) => oldest_turn != Some(turn), + None => oldest_turn.is_none(), + }; + if other_turn { + break; + } + } + count += 1; + bytes += size; + if let Some((turn, is_header)) = record { + oldest_turn = Some(turn); + oldest_is_header = is_header; + if is_header { + oldest_header = Some(seq); + } + } + // Past the budget, control events join only between records of the + // turn; trailing ones wait for the next page. + if count <= limit || record.is_some() { + start = Some(seq); + } + } + bounded = bounded || bytes >= PAGE_BYTES || count >= MAX_PAGE_EVENTS; + if bounded && !oldest_is_header && oldest_header.is_some() { + start = oldest_header; + } + BackwardPage { + start, + settled: bounded + || other_turn + || (count >= limit && (oldest_is_header || oldest_turn.is_none())), + } + } + fn read(&self, stream_id: &str, request: &StreamReadRequest) -> Result { if request.after.is_some() && request.before.is_some() { bail!("read_stream accepts either after or before, not both"); @@ -244,17 +333,17 @@ impl StreamLog { iter.peek().is_some() } else { let before = request.before.unwrap_or(u64::MAX); - let mut iter = self.events.range(..before).rev().peekable(); - while let Some((_, (event, size))) = iter.peek() { - if !events.is_empty() && (events.len() >= limit || bytes + size > PAGE_BYTES) { - break; + match self.backward_page(before, limit).start { + Some(start) => { + events.extend( + self.events + .range(start..before) + .map(|(_, (event, _))| event.clone()), + ); + self.events.range(..start).next().is_some() } - events.push((*event).clone()); - bytes += size; - iter.next(); + None => false, } - events.reverse(); - iter.peek().is_some() }; Ok(StreamPage { stream_id: stream_id.to_owned(), @@ -566,23 +655,11 @@ impl HostStreamHub { .get_mut(&request.stream_id) .context("History stream expired")?; let before = request.before.unwrap_or(u64::MAX); - let mut count = 0; - let mut bytes = 0; - let mut page_full = false; - // Inspect sizes by reference. Cloning the growing page once per - // inserted record turns a bounded read into quadratic copying. - for (_, (_, size)) in log.events.range(..before).rev() { - if count > 0 && (count >= limit || bytes + size > PAGE_BYTES) { - page_full = true; - break; - } - count += 1; - bytes += size; - } - if count >= limit || bytes >= PAGE_BYTES || page_full { + let page = log.backward_page(before, limit); + if page.settled { break; } - if log.truncated && count == 0 { + if log.truncated && page.start.is_none() { bail!("Older history exceeded the host stream memory budget; reopen the session to reload its latest page"); } let Some(history) = log.history.as_mut() else { @@ -898,10 +975,17 @@ mod tests { "item":{"type":"text","data":{"id":format!("{turn}-{item}"),"content":"original"}}}) } + fn history_header(turn: usize) -> Value { + serde_json::json!({"sessionId":"s", "id":format!("turn/t{turn}"), + "turn":{"turnId":format!("t{turn}"), "turnIndex":turn}}) + } + async fn history_fixture(before: Option) -> Result { let turn = before.unwrap_or(100).saturating_sub(1); + let mut records = vec![history_header(turn)]; + records.extend((0..3).map(|item| history_record(turn, item))); Ok(HistoryBatch { - records: (0..3).map(|item| history_record(turn, item)).collect(), + records, before: (turn > 0).then_some(turn), }) } @@ -920,9 +1004,9 @@ mod tests { }; let first = hub.read_history("phone", &request, load).await.unwrap(); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!(first.events.len(), 2); + assert_eq!(first.events.len(), 4, "the budget grows to the turn header"); assert!(first.has_more); - assert_eq!(first.events[0].payload["id"], "item/99-1"); + assert_eq!(first.events[0].payload["id"], "turn/t99"); assert!(first.cursor < JS_MAX_SAFE_INTEGER); let wire = serde_json::to_string(&first).unwrap(); assert_eq!(serde_json::from_str::(&wire).unwrap(), first); @@ -965,16 +1049,158 @@ mod tests { } #[tokio::test] - async fn paged_history_live_updates_remove_stale_pending_records() { + async fn history_pages_hold_whole_turns_and_continue_exactly_before_them() { let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let reads = std::sync::atomic::AtomicUsize::new(0); + let load = |before| { + reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + history_fixture(before) + }; + // The budget runs out in the second turn; the third is never read. let request = StreamReadRequest { - limit: Some(1), + limit: Some(5), ..request("s") }; - let first = hub - .read_history("phone", &request, history_fixture) + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 8); + assert_eq!(first.events[0].payload["id"], "turn/t98"); + assert!(first.has_more); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 2); + let older = hub + .read_history( + "phone", + &StreamReadRequest { + before: Some(first.events[0].seq), + epoch: Some(first.epoch), + ..request + }, + load, + ) .await .unwrap(); + assert_eq!(older.cursor, first.cursor); + assert_eq!(older.events[0].payload["id"], "turn/t96"); + assert_eq!( + older.events.last().unwrap().seq + 1, + first.events[0].seq, + "the older page continues exactly before the first" + ); + assert!(older + .events + .iter() + .all(|event| !["t98", "t99"] + .contains(&event.payload["turn"]["turnId"].as_str().unwrap()))); + } + + #[tokio::test] + async fn history_pages_give_way_to_a_turn_that_cannot_fit() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let load = |before: Option| async move { + let (turn, items, older) = match before { + None => (1, 2, Some(1)), + Some(_) => (0, 1000, None), + }; + let mut records = vec![history_header(turn)]; + records.extend((0..items).map(|item| history_record(turn, item))); + Ok(HistoryBatch { + records, + before: older, + }) + }; + let mut request = StreamReadRequest { + limit: Some(5), + ..request("s") + }; + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 3, "only the whole newer turn travels"); + assert_eq!(first.events[0].payload["id"], "turn/t1"); + assert!(first.has_more); + // The enormous turn pages through its hard bound with nothing skipped. + let mut seen = HashSet::new(); + request.before = Some(first.events[0].seq); + request.epoch = Some(first.epoch); + loop { + let page = hub.read_history("phone", &request, load).await.unwrap(); + assert!(!page.events.is_empty() && page.events.len() <= MAX_PAGE_EVENTS); + for event in &page.events { + assert!(seen.insert(event.payload["id"].as_str().unwrap().to_owned())); + } + request.before = Some(page.events[0].seq); + if !page.has_more { + assert_eq!(page.events[0].payload["id"], "turn/t0"); + break; + } + } + assert_eq!(seen.len(), 1001); + } + + #[tokio::test] + async fn latest_pages_find_turn_boundaries_among_live_records() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + hub.activate("s"); + let turn = |id: &str, status: &str| serde_json::json!({"sessionId":"s","id":format!("turn/{id}"),"turn":{"turnId":id,"status":status}}); + let item = |turn: &str, id: &str| serde_json::json!({"sessionId":"s","id":format!("item/{id}"),"turn":{"turnId":turn},"item":{"type":"text","data":{"id":id}}}); + let (a, b, c) = (item("one", "a"), item("one", "b"), item("two", "c")); + let records = vec![ + turn("one", "inprogress"), + a.clone(), + b.clone(), + turn("two", "inprogress"), + c.clone(), + ]; + hub.synchronize_records("s".into(), true, || async { Ok(records) }) + .await + .unwrap(); + hub.append("s".into(), "note".into(), serde_json::json!({})) + .await + .unwrap(); + let ids = |page: &StreamPage| -> Vec { + page.events + .iter() + .map(|event| event.payload["id"].as_str().unwrap_or("note").to_owned()) + .collect() + }; + let small = |limit| StreamReadRequest { + limit: Some(limit), + ..request("s") + }; + // Control events are looked through while the turn is still open... + let latest = hub.read("d", &small(2)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/two", "item/c", "note"]); + assert!(latest.has_more); + // ...and a status change moves only the header above the items it heads: + // a budget spent on it ends the page there, the items follow older. + let (a2, b2) = (a.clone(), b.clone()); + hub.synchronize_records("s".into(), false, || async { + Ok(vec![turn("one", "completed"), a2, b2]) + }) + .await + .unwrap(); + let latest = hub.read("d", &small(1)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/one"]); + assert!(latest.has_more); + let latest = hub.read("d", &small(3)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/two", "item/c", "note", "turn/one"]); + } + + #[tokio::test] + async fn paged_history_live_updates_remove_stale_pending_records() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let request = StreamReadRequest { + limit: Some(1), + ..request("s") + }; + // An item filling the byte bound travels alone, whatever its turn holds. + let load = |before| async move { + let mut batch = history_fixture(before).await?; + if before.is_none() { + batch.records.last_mut().unwrap()["item"]["data"]["content"] = + Value::from("x".repeat(PAGE_BYTES)); + } + Ok(batch) + }; + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 1); // Two old items still wait in the pending batch. The live turn deletes // them, and changes the item already delivered in the first page. let mut updated = history_record(99, 2); @@ -989,7 +1215,7 @@ mod tests { after: Some(first.cursor), ..request.clone() }, - history_fixture, + load, ) .await .unwrap(); @@ -1006,7 +1232,7 @@ mod tests { before: Some(first.events[0].seq), ..request }, - history_fixture, + load, ) .await .unwrap(); 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 07dbd6e479..9a704f3e3e 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 @@ -11,6 +11,13 @@ use std::{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. +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."; #[derive(Deserialize)] @@ -148,6 +155,7 @@ impl Reader { .read(StreamReadRequest { stream_id: self.session_id.clone(), subscribe: true, + limit: Some(FIRST_PAGE_EVENTS), ..Default::default() }) .await?; @@ -582,6 +590,62 @@ mod tests { assert_eq!(lab.transport.reads.lock().unwrap().len(), reads_before); } + #[tokio::test] + async fn opening_reads_a_small_latest_page_first() { + let lab = lab(); + let _subscriber = lab.subscribe("s").await.unwrap(); + let reads = lab.transport.reads.lock().unwrap().clone(); + assert_eq!(reads[0].limit, Some(super::FIRST_PAGE_EVENTS)); + } + + #[tokio::test] + async fn opening_a_long_session_shows_whole_turns_and_pages_older_ones_after_them() { + let lab = lab(); + lab.hub.activate("s"); + let mut records = Vec::new(); + for turn in 0..5 { + records.push(record("s", &format!("t{turn}"), "completed")); + for item in 0..19 { + let mut body = record("s", &format!("t{turn}"), "completed"); + let id = format!("{turn}-{item}"); + body["id"] = serde_json::json!(format!("item/{id}")); + body["round"] = + serde_json::json!({"id":format!("r{turn}"),"turnId":format!("t{turn}")}); + body["item"] = serde_json::json!({"type":"text","data":{"id":id}}); + records.push(body); + } + } + lab.hub + .synchronize_records("s".into(), true, || async { Ok(records) }) + .await + .unwrap(); + let subscriber = lab.subscribe("s").await.unwrap(); + let ids = |lab: &Lab| -> Vec { + lab.events + .lock() + .unwrap() + .iter() + .filter(|(event, _)| event == "session-record") + .map(|(_, payload)| payload["id"].as_str().unwrap().to_owned()) + .collect() + }; + // 48 events run out inside t2; the page grows back to its header. + let first = ids(&lab); + assert_eq!(first.len(), 60); + assert_eq!(first[0], "turn/t2"); + let ready = lab.events.lock().unwrap().last().unwrap().1.clone(); + assert_eq!(ready["hasMore"], true); + // The older page brings only turns the first page does not touch. + subscriber.load_older().await.unwrap(); + let all = ids(&lab); + assert_eq!(all.len(), 100); + assert_eq!(all[60], "turn/t0"); + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), 100); + let ready = lab.events.lock().unwrap().last().unwrap().1.clone(); + assert_eq!(ready["hasMore"], false); + } + #[tokio::test] async fn host_restart_is_announced_as_a_gap_and_replayed_from_the_latest_page() { let lab = lab(); diff --git a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs index 3f6697d168..ea112430a2 100644 --- a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs @@ -3,6 +3,7 @@ use anyhow::{anyhow, bail, Result}; use futures::{stream::FuturesUnordered, StreamExt}; use serde_json::{json, Value}; +use sioc::error::AckError; use sioc::prelude::*; use std::time::Duration; @@ -31,6 +32,21 @@ impl RpcCallDeadlines { } } +/// Stable text for an acknowledgement the socket lost because its connection +/// closed (a reconnect, a device switch, a revoked socket) before the Relay +/// answered. The frontend matches this prefix; keep it byte-stable. +pub const RELAY_REPLY_LOST: &str = + "Relay connection closed before the reply arrived; delivery outcome is unknown"; + +/// The Relay always answers an acknowledged event, so a dropped acknowledgement +/// means this socket went away. Say so instead of the transport's own text. +fn ack_error(error: AckError) -> anyhow::Error { + match error { + AckError::Recv(_) => anyhow!(RELAY_REPLY_LOST), + other => anyhow::Error::from(other), + } +} + pub(crate) fn rpc_call_payload(target: &str, params: Value, timeout: Duration) -> Value { let timeout_ms = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX); json!({"method":format!("{target}:invoke"),"params":params,"timeoutMs":timeout_ms}) @@ -169,7 +185,8 @@ impl RealtimeConnection { )) .await? .timeout(Duration::from_secs(10)) - .await?; + .await + .map_err(ack_error)?; if ack.payload.0["ok"] != true { bail!("Relay RPC registration rejected"); } @@ -242,7 +259,8 @@ impl RealtimeSender { )) .await? .timeout(Duration::from_secs(10)) - .await?; + .await + .map_err(ack_error)?; if response.payload.0["ok"] != true { bail!( "Relay event rejected: {}", @@ -274,7 +292,7 @@ impl RealtimeSender { .await? .timeout(deadlines.ack) .await - .map_err(anyhow::Error::from) + .map_err(ack_error) }) .await .map_err(|_| anyhow!("Relay RPC timed out; delivery outcome is unknown"))??; @@ -307,6 +325,15 @@ impl RealtimeSender { mod tests { use super::*; + #[tokio::test] + async fn a_dropped_acknowledgement_reads_as_a_lost_reply() { + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + drop(sender); + let dropped = receiver.await.unwrap_err(); + let error = ack_error(AckError::Recv(dropped)); + assert_eq!(error.to_string(), RELAY_REPLY_LOST); + } + #[test] fn default_rpc_payload_keeps_the_legacy_deadlines() { let deadlines = RpcCallDeadlines::for_timeout(DEFAULT_RPC_TIMEOUT); diff --git a/src/shared/dialog-queue/HostDialogQueue.ts b/src/shared/dialog-queue/HostDialogQueue.ts index ed37f73b52..bbef59ecdd 100644 --- a/src/shared/dialog-queue/HostDialogQueue.ts +++ b/src/shared/dialog-queue/HostDialogQueue.ts @@ -254,9 +254,16 @@ export function observeHostQueue(queue: HostDialogQueue): () => void { const refresh = async () => { if (closed || running) return; if (timer) clearTimeout(timer); + // A hidden window is refreshed when it is shown again. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; running = true; - let delay = 2_000; - try { await queue.refresh(); } catch { delay = 10_000; } + let delay = 10_000; + try { + // Watch closely only while something is queued; an empty queue changes + // through this window's own sends (which refresh it) or rarely elsewhere. + const snapshot = await queue.refresh(); + if ((snapshot?.items?.length ?? 0) > 0 || snapshot?.activeTurnId) delay = 2_000; + } catch { delay = 10_000; } finally { running = false; if (!closed) timer = setTimeout(() => void refresh(), delay); } }; const wake = () => { if (document.visibilityState !== 'hidden') void refresh(); }; diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 7994df19f0..861c984a76 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -206,13 +206,13 @@ also run the focused state contracts: pnpm --dir src/web-ui run test:run src/app/services/sessionSceneLifecycle.test.ts src/flow_chat/services/sessionActivation.test.ts src/flow_chat/services/storeSync.test.ts src/app/stores/sceneStore.test.ts ``` -For top workspace tab recent-session disclosure, selection and reconciliation: +For top session tabs (one per session, kept per device), selection and reconciliation: ```bash pnpm --dir src/web-ui run test:run src/app/components/SceneBar/SceneBar.test.tsx src/app/stores/sceneStore.test.ts src/app/services/sessionSceneLifecycle.test.ts src/app/hooks/useSessionTabLabels.test.tsx src/shared/ui/MenuPopover.test.tsx src/shared/ui/MenuComposition.contract.test.ts ``` -These local DOM/store fixtures cover bounded history, legacy tabs and simulated +These local DOM/store fixtures cover the session tab limit, other-device tabs and simulated SSH/Peer identities; they do not establish native rendering or live remote behavior. For composer mode restoration, passive synchronization, or explicit mode selection: diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 40b785806b..2a219bfcfa 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -776,7 +776,7 @@ function App() { let disposed = false; let unlisten: (() => void) | null = null; void import('@tauri-apps/api/event') - .then(({ listen }) => listen<{ sessionId?: string }>( + .then(({ listen }) => listen<{ sessionId?: string; surfaceId?: string; workspaceId?: string }>( 'agent-companion://open-session', async event => { if (disposed) return; @@ -784,7 +784,11 @@ function App() { if (!sessionId) return; const { openAgentCompanionSession } = await import('./services/openAgentCompanionSession'); - await openAgentCompanionSession(sessionId); + try { + await openAgentCompanionSession(sessionId, event.payload?.surfaceId, event.payload?.workspaceId); + } catch (error) { + log.warn('Failed to open Agent companion session', { sessionId, error }); + } try { await api.invoke('show_main_window'); diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index f04f849c0e..3618010a71 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -235,8 +235,15 @@ &__bubbles { position: relative; flex-shrink: 0; - width: 180px; - max-width: 180px; + /* Bubbles stay 180px; the inline-end room (open space before the pet) + holds their bottom-right shadow, and the negative margin keeps the pet + where it was. */ + width: calc(180px + var(--_bubble-shadow-room)); + max-width: calc(180px + var(--_bubble-shadow-room)); + padding-inline-end: var(--_bubble-shadow-room); + margin-inline-end: calc(-1 * var(--_bubble-shadow-room)); + --_bubble-shadow-room: 28px; + box-sizing: border-box; min-height: 0; max-height: 100vh; /* The slot clips (`overflow: hidden`), so the buffer has to sit inside it: @@ -367,11 +374,12 @@ 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. Everything stays - inside the bubble column, which clips sideways. */ + 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), - 0 10px 20px -12px color-mix(in srgb, var(--_bubble-light) 34%, transparent); + 10px 10px 24px -10px color-mix(in srgb, var(--_bubble-light) 40%, transparent); padding: 9px 12px; border: none; border-radius: 14px 14px 4px 14px; @@ -521,7 +529,7 @@ ), var(--_bubble-glow); box-shadow: var(--_bubble-edge), - 0 0 0 0 color-mix(in srgb, var(--openbitfun-color-status-warning-content) 50%, transparent); + inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 40%, transparent); animation: openbitfun-agent-companion-attention-pulse 1.5s ease-in-out infinite !important; } @@ -575,18 +583,20 @@ } @keyframes openbitfun-agent-companion-attention-pulse { + /* The pulse breathes inside the bubble's edge: an outward ring would be + clipped by the column on both sides. */ 0%, 100% { box-shadow: var(--_bubble-edge), - 0 0 0 0 color-mix(in srgb, var(--openbitfun-color-status-warning-content) 40%, transparent); + inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 18%, transparent); transform: translateY(0); } 50% { box-shadow: var(--_bubble-edge), - 0 0 0 6px transparent; + inset 0 0 0 1.5px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 55%, transparent); transform: translateY(-1px); } } diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index ccbcc53906..4a15489238 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -766,7 +766,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { } // Finished / failed / interrupted bubbles are pure "unread" notices, so tell // the main window the user has seen this one. - void sendPetCommand({ type: 'dismiss-task', sessionId: task.sessionId }) + void sendPetCommand({ type: 'dismiss-task', sessionId: task.sessionId, ...(task.surfaceId ? { surfaceId: task.surfaceId } : {}) }) .catch(error => { log.warn('Failed to acknowledge Agent companion task', { sessionId: task.sessionId, @@ -1014,7 +1014,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { const openTaskSession = async (task: AgentCompanionTaskStatus) => { try { - await emit('agent-companion://open-session', { sessionId: task.sessionId }); + await emit('agent-companion://open-session', { + sessionId: task.sessionId, + ...(task.surfaceId ? { surfaceId: task.surfaceId, workspaceId: task.workspaceId } : {}), + }); await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to open Agent companion task session', { @@ -1126,7 +1129,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { - {statusLabel} + {task.deviceName ? `${statusLabel} · ${task.deviceName}` : statusLabel} {isSingleTask && task.latestOutput && (() => { const typedOutput = typedOutputBySessionId[task.sessionId]; diff --git a/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx b/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx index 9c80924511..1023a3d84b 100644 --- a/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx +++ b/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx @@ -203,6 +203,12 @@ it('still reports a failed status read as unavailable and permits Stop', async ( await act(async () => [...container.querySelectorAll('button')].find(b => b.textContent === 'computerControl.stop')!.click()); expect(mocks.stop).toHaveBeenCalledWith(4); }); +it('stops asking a host that refuses device control', async () => { + mocks.status.mockRejectedValue(new Error("command 'computer_use_control_status' is not supported on CLI peer host: no automation runtime")); + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTime(5000)); + expect(mocks.status).toHaveBeenCalledOnce(); +}); it('shows the preview by default and hides the card when control ends externally', async () => { mocks.status.mockResolvedValue(active('Window A')); mocks.preview.mockResolvedValue({ generation: 4, target: 'Window A', image_base64: 'AA==', mime_type: 'image/png', diff --git a/src/web-ui/src/app/components/ComputerUseControlCard.tsx b/src/web-ui/src/app/components/ComputerUseControlCard.tsx index c78c18c685..d58846c7a3 100644 --- a/src/web-ui/src/app/components/ComputerUseControlCard.tsx +++ b/src/web-ui/src/app/components/ComputerUseControlCard.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Button, Card, CardBody, CardFooter, CardHeader, OverlayLayer, OverflowText } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal'; import { computerUseControlAPI, projectControlPointer, type ComputerUseControlSnapshot, type ComputerUseControlPreview, @@ -44,6 +45,7 @@ export function ComputerUseControlCard() { useEffect(() => { let disposed = false; + let refused = false; let timer: ReturnType | undefined; setView(undefined); setStopping(false); @@ -82,14 +84,17 @@ export function ComputerUseControlCard() { }); } } - } catch { + } catch (error) { + // A host that refuses the command has no device control: asking it + // again every second only competes with real work for the link. + refused = classifyPeerHostRefusal(error) !== null; // Older peers may not expose this capability. Retain a previously // active card with an explicit unavailable state, never a local fallback. if (!disposed && scope.isCurrent() && revision === mutation.current) setView(previous => previous && { ...previous, frame: undefined, framePointer: undefined, error: true, }); } finally { - if (!disposed && scope.isCurrent()) timer = setTimeout(() => void poll(), 1000); + if (!disposed && !refused && scope.isCurrent()) timer = setTimeout(() => void poll(), 1000); } }; void poll(); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx index 5cbd7bfb11..fb1757b5cf 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx @@ -56,6 +56,16 @@ vi.mock('./components/StickySectionHeader', () => ({ default: ({ children }: Rea vi.mock('./components/WorkspaceSessionGroupingToggle', () => ({ default: () => null })); vi.mock('./components/WorkspaceSessionFilterMenu', () => ({ default: () => null })); vi.mock('./sections/workspaces/WorkspaceListSection', () => ({ default: () => null })); +vi.mock('./sections/devices/DeviceWorkspaceSections', () => ({ default: () => null })); +vi.mock('./sections/devices/useSidebarDevices', () => ({ useSidebarDevices: () => [] })); +vi.mock('./sections/devices/openOnDevice', () => ({ activateDeviceSlot: vi.fn() })); +vi.mock('@/infrastructure/account/deviceDirectory', async importOriginal => ({ + ...await importOriginal(), + useDeviceDirectory: (select?: (state: { devices: never[]; localId: string | null; status: 'idle' }) => unknown) => { + const directory = { devices: [], localId: null, status: 'idle' as const }; + return select ? select(directory) : directory; + }, +})); vi.mock('./components/MiniAppShortcuts', () => ({ default: () => })); vi.mock('@/features/ssh-remote', () => ({ useSSHRemoteContext: () => state.remote, diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index f0de86d6ed..f8290147a7 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -7,7 +7,7 @@ * 3. Unified Sessions (all or grouped by project / assistant) */ -import React, { Suspense, useCallback, useState, useEffect, useRef, useSyncExternalStore } from 'react'; +import React, { Suspense, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'; import { OverflowText, Icon, @@ -29,7 +29,12 @@ import StickySectionHeader from './components/StickySectionHeader'; import WorkspaceSessionGroupingToggle from './components/WorkspaceSessionGroupingToggle'; import WorkspaceSessionFilterMenu from './components/WorkspaceSessionFilterMenu'; import { useMiniAppNavigation } from '@/app/scenes/miniapps/miniAppNavigationStore'; -import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; +import DeviceWorkspaceSections from './sections/devices/DeviceWorkspaceSections'; +import { devicePlatform, useSidebarDevices } from './sections/devices/useSidebarDevices'; +import { activateDeviceSlot } from './sections/devices/openOnDevice'; +import { useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import type { NewProjectDeviceOption, NewProjectDeviceTarget } from '../NewProjectDialog/NewProjectDialog'; import { useSceneStore } from '../../stores/sceneStore'; import { useMiniAppCatalogSync } from '../../scenes/miniapps/hooks/useMiniAppCatalogSync'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -190,6 +195,26 @@ const MainNav: React.FC = () => { return () => window.removeEventListener('nav:new-project', onNewProject); }, []); + const peerDevice = usePeerDeviceModeOptional(); + const signedIn = useDeviceDirectory(state => state.localId !== null); + const deviceSlots = useSidebarDevices(t('deviceOverview.thisDevice')); + const deviceOptions = useMemo(() => (signedIn + ? deviceSlots.filter(slot => !slot.isLocal).map(slot => ({ + surfaceId: slot.surfaceId, + name: slot.name, + hostKind: slot.hostKind, + platform: devicePlatform(slot.os), + rendered: slot.rendered, + controllable: slot.controllable, + })) + : undefined), [deviceSlots, signedIn]); + /** Creation happens on the rendered device; render the chosen one first. */ + const handleSelectCreationDevice = useCallback(async (target: NewProjectDeviceTarget) => { + const slot = deviceSlots.find(candidate => (target === 'local' ? candidate.isLocal : candidate.surfaceId === target)); + if (!slot) throw new Error(t('newWorkspace.deviceError')); + return await activateDeviceSlot(peerDevice, slot) === 'activated'; + }, [deviceSlots, peerDevice, t]); + const handleCreateAssistant = useCallback(async () => { const scope = getActiveSurfaceScope(); const workspace = await workspaceManager.createAssistantWorkspace(); @@ -310,7 +335,7 @@ const MainNav: React.FC = () => { />
- +
@@ -327,6 +352,8 @@ const MainNav: React.FC = () => { onPickRemoteFolder={handlePickRemoteFolder} onSelectRemoteHost={handleSelectRemoteHost} onCreateAssistant={showAssistantChoice ? handleCreateAssistant : undefined} + onSelectDevice={showAssistantChoice && peerDevice ? handleSelectCreationDevice : undefined} + deviceOptions={deviceOptions} defaultParentPath={currentWorkspace && !isRemoteWorkspace(currentWorkspace) ? currentWorkspace.rootPath : undefined} data-testid="nav-workspace-dialog" /> diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 7aa6cd8f34..03471b0891 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1577,7 +1577,9 @@ $_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); - --_device-overview-light: var(--openbitfun-color-accent-default); + // 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. + --_device-overview-light: var(--openbitfun-color-content-secondary); --_device-overview-light-strength: 16%; position: fixed; z-index: var(--openbitfun-layer-popover); @@ -1606,10 +1608,6 @@ $_section-header-height: 22px; --_device-overview-light-strength: 26%; } - &[data-control-state='local'] { - --_device-overview-light: var(--openbitfun-color-accent-secondary); - } - &[data-control-state='active'] { --_device-overview-light: var(--openbitfun-color-status-success-content); } @@ -1880,8 +1878,9 @@ $_section-header-height: 22px; flex: 0 0 var(--openbitfun-control-height-sm); pointer-events: auto; color: var(--openbitfun-color-content-secondary); - background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 58%, transparent); - box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) var(--openbitfun-color-border-subtle); + // Soft glass with no outline: the card has one edge, its own. + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 7%, transparent); + backdrop-filter: var(--openbitfun-effect-blur-subtle); transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); @@ -1893,7 +1892,7 @@ $_section-header-height: 22px; &:is(:hover, :focus-visible):not(:disabled) { color: var(--openbitfun-color-content-primary); - background: var(--openbitfun-color-surface-raised); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 13%, transparent); } &:disabled { @@ -2033,10 +2032,81 @@ $_section-header-height: 22px; } } +// This machine's home mark sits where other devices have their action: the +// same tinted glass disc, as a status rather than a button. .openbitfun-device-overview__local-mark { display: inline-flex; flex: 0 0 auto; - color: var(--openbitfun-color-content-muted); + align-items: center; + justify-content: center; + inline-size: var(--openbitfun-control-height-md); + block-size: var(--openbitfun-control-height-md); + border-radius: var(--openbitfun-radius-pill); + background: color-mix(in srgb, var(--_device-overview-light) 20%, transparent); + color: color-mix(in srgb, var(--_device-overview-light) 72%, var(--openbitfun-color-content-primary)); +} + +// Renaming sits right after the name: a small quiet pencil that brightens +// with the heading it edits. +.openbitfun-device-overview__alias-edit { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-content: var(--openbitfun-color-content-muted); + --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-light) 16%, transparent); + flex: 0 0 auto; + opacity: 0.7; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + .openbitfun-device-overview__device-heading:hover &, + &:focus-visible { opacity: 1; } +} + +.openbitfun-device-overview__alias-editor { + display: flex; + flex: 1 1 auto; + align-items: center; + min-width: 0; +} + +// Editing keeps the heading's type and place: no box, only an underline lit +// by the card's state light. +.openbitfun-device-overview__alias-input { + flex: 1 1 auto; + min-width: 0; + gap: var(--openbitfun-space-1); + padding-inline: 0; + border: 0; + border-block-end: var(--openbitfun-border-width-default) solid + color-mix(in srgb, var(--_device-overview-light) 35%, transparent); + border-radius: 0; + + &, + &:hover, + &:focus-within { background: transparent; } + + &:focus-within { + border-block-end-color: color-mix(in srgb, var(--_device-overview-light) 80%, transparent); + } + + input { + font-family: var(--openbitfun-type-heading-card-font-family); + font-size: var(--openbitfun-type-heading-card-font-size); + font-weight: var(--openbitfun-type-heading-card-font-weight); + letter-spacing: var(--openbitfun-type-heading-card-letter-spacing); + caret-color: var(--_device-overview-light); + } + + input::placeholder { color: var(--openbitfun-color-content-muted); } +} + +.openbitfun-device-overview__alias-action { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-content: var(--openbitfun-color-content-secondary); + --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-light) 18%, transparent); + border-radius: var(--openbitfun-radius-pill); + + &:first-child { --_icon-button-content: color-mix(in srgb, var(--_device-overview-light) 72%, var(--openbitfun-color-content-primary)); } } .openbitfun-device-overview__device-name { @@ -2083,18 +2153,13 @@ $_section-header-height: 22px; // The primary and fill variants paint an opaque base under their tint; the // glass tint must sit on the lit card instead. background: transparent; - transition: box-shadow var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); &:not(:disabled, [aria-disabled='true']) { --_icon-button-content: color-mix(in srgb, var(--_device-overview-action-tone) 72%, var(--openbitfun-color-content-primary)); - --_icon-button-background: color-mix(in srgb, var(--_device-overview-action-tone) 16%, transparent); - --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-action-tone) 26%, transparent); - --_icon-button-background-active: color-mix(in srgb, var(--_device-overview-action-tone) 32%, transparent); - box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--_device-overview-action-tone) 30%, transparent); - } - - &:not(:disabled, [aria-disabled='true']):is(:hover, :focus-visible) { - box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--_device-overview-action-tone) 48%, transparent); + --_icon-button-background: color-mix(in srgb, var(--_device-overview-action-tone) 20%, transparent); + --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-action-tone) 30%, transparent); + --_icon-button-background-active: color-mix(in srgb, var(--_device-overview-action-tone) 36%, transparent); } // Unavailable reads as unavailable: a quiet neutral disc, no state light. @@ -2103,7 +2168,6 @@ $_section-header-height: 22px; --_icon-button-background: color-mix(in srgb, var(--openbitfun-color-content-primary) 5%, transparent); --_icon-button-background-hover: var(--_icon-button-background); --_icon-button-background-active: var(--_icon-button-background); - box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) var(--openbitfun-color-border-subtle); cursor: not-allowed; } 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 0350c6ebe7..e1cde0d5aa 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 @@ -12,6 +12,8 @@ import { getDeviceArtworkKind } from './deviceArtworkKind'; import DeviceStatusControl from './DeviceStatusControl'; const state = vi.hoisted(() => ({ + relayCapabilities: vi.fn(async () => ['device_alias_v1']), + updateDevice: vi.fn(async () => undefined), overview: null as DeviceInterconnectionOverview | null, refresh: vi.fn().mockResolvedValue(undefined), switchToLocal: vi.fn().mockResolvedValue('activated'), @@ -67,12 +69,14 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOrigina remoteConnectAPI: { getDeviceInfo: state.getDeviceInfo, accountListDevices: state.accountListDevices, + accountRelayCapabilities: state.relayCapabilities, + accountUpdateDevice: state.updateDevice, }, })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { listen: () => () => {} }, })); -const notifications = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn() })); +const notifications = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() })); vi.mock('@/shared/notification-system', () => ({ useNotification: () => notifications, })); @@ -259,7 +263,8 @@ describe('device status card', () => { expect(element('nav-device-status-local').getAttribute('role')).toBe('img'); expect(element('nav-device-status-local').getAttribute('aria-label')).toBe('deviceOverview.localDevice'); expect(element('nav-device-status-local').querySelector('.lucide-house')).not.toBeNull(); - expect(element('nav-device-status-local').closest('.openbitfun-device-overview__device-heading')).not.toBeNull(); + // Where other devices have their action, not after the name. + expect(element('nav-device-status-local').closest('.openbitfun-device-overview__device-actions')).not.toBeNull(); expect(card.querySelector('[data-openbitfun-component="status-pill"]')).toBeNull(); expect(card.querySelector('.openbitfun-device-overview__carousel-controls')).toBeNull(); expect(card.querySelector('[data-openbitfun-part="footer"]')).toBeNull(); @@ -397,6 +402,31 @@ describe('device status card', () => { expect(state.switchToDevice).not.toHaveBeenCalled(); }); + it('renames the shown device in place through the Relay', async () => { + state.identity = { status: 'signed-in', me: { user: { accountId: 'acct' } } }; + state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'This computer' }); + state.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'This computer', online: true }]); + await act(async () => { render(); }); + await act(async () => { element('nav-device-alias-edit').click(); }); + const input = element('nav-device-alias-input') as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Studio'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { element('nav-device-alias-save').click(); }); + expect(state.updateDevice).toHaveBeenCalledWith('local', 'Studio'); + expect(document.querySelector('[data-testid="nav-device-alias-input"]')).toBeNull(); + }); + + it('offers no rename through a Relay without device aliases', async () => { + state.identity = { status: 'signed-in', me: { user: { accountId: 'acct' } } }; + state.relayCapabilities.mockResolvedValueOnce([]); + state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'This computer' }); + state.accountListDevices.mockResolvedValue([{ device_id: 'local', device_name: 'This computer', online: true }]); + await act(async () => { render(); }); + expect(document.querySelector('[data-testid="nav-device-alias-edit"]')).toBeNull(); + }); + it('hides pagination and navigation after loading a single local device', async () => { state.identity = { status: 'signed-in', me: { user: { accountId: 'acct' } } }; state.getDeviceInfo.mockResolvedValue({ device_id: 'local', device_name: 'This computer' }); @@ -540,9 +570,9 @@ describe('device status card', () => { expect(element('nav-device-status-connect').dataset.openbitfunComponent).toBe('icon-button'); expect(element('nav-device-status-connect').dataset.openbitfunShape).toBe('circle'); expect(element('nav-device-status-connect').dataset.openbitfunVariant).toBe('primary'); - expect(element('nav-device-status-connect').getAttribute('aria-label')).toMatch(/ · deviceOverview\.connectAndUse$/); - // Connecting and switching to an attached device are told apart by glyph. - expect(element('nav-device-status-connect').querySelector('.lucide-link')).not.toBeNull(); + expect(element('nav-device-status-connect').getAttribute('aria-label')).toMatch(/ · deviceOverview\.switchUse$/); + // Attached or not, every device offers the same switch action. + expect(element('nav-device-status-connect').querySelector('.lucide-arrow-right-left')).not.toBeNull(); expect(element('nav-device-status-connect').textContent).toBe(''); state.attachedHostKinds = { 'peer-2': 'cli' }; render(); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 2d60d8ae67..eb3245b12b 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,11 +1,13 @@ -import { useDeviceDirectory, resolveDeviceName, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; +import { useDeviceDirectory, resolveDeviceName, isDeviceControllable, deviceClientVersion, refreshDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { subscribeOverlayInteraction, createOverlayPortal, OverflowText, Button, Card, CardBody, CardHeader, Icon, IconButton, ScrollArea, type IconSize } from '@openbitfun/ui'; -import { ArrowRightLeft, ChevronLeft, ChevronRight, CircleAlert, House, Link, MessageCircle, Monitor, MonitorSmartphone, Smartphone, Undo2, Unplug } from 'lucide-react'; +import { subscribeOverlayInteraction, createOverlayPortal, OverflowText, Button, Card, CardBody, CardHeader, Icon, IconButton, Input, ScrollArea, type IconSize } from '@openbitfun/ui'; +import { ArrowRightLeft, Check, ChevronLeft, ChevronRight, CircleAlert, House, MessageCircle, Pencil, X, Monitor, MonitorSmartphone, Smartphone, Undo2, Unplug } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { LOCAL_SURFACE_ID, surfaceIdForDevice } from '@/infrastructure/peer-device/deviceSurface'; import { useAccountIdentity } from '@/infrastructure/account-identity'; import { remoteConnectAPI, type AccountDeviceInfo } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; @@ -23,6 +25,8 @@ import { type DeviceOverviewHostKind, } from '../deviceInterconnectionOverview'; import { useDeviceInterconnectionOverview } from './useDeviceInterconnectionOverview'; +import { DEVICES_DIALOG_MORPH_KEY } from '@/app/components/RemoteConnectDialog/morphKey'; +import { rememberMorphOrigin } from '@/app/services/surfaceMorph'; import { DeviceArtwork } from './DeviceArtwork'; import { DeviceSystemGlyph } from './DeviceSystemGlyph'; import { useDevicePreviewMotion } from './useDevicePreviewMotion'; @@ -100,14 +104,14 @@ const DeviceStatusControl: React.FC = ({ }) => { useDeviceDirectory(); const { t, formatNumber } = useI18n('common'); - const { warning } = useNotification(); + const { warning, error: notifyError } = useNotification(); const peerContext = usePeerDeviceModeOptional(); const identity = useAccountIdentity(); const accountId = identity.status === 'signed-in' ? (identity.me?.user.accountId ?? identity.me?.user.githubId) : undefined; const [switchTargets, setSwitchTargets] = useState<{ accountId: string | number; localId: string; devices: AccountDeviceInfo[]; } | null>(null); - const [switchingAction, setSwitchingAction] = useState<'connect' | 'switch' | 'disconnect' | null>(null); + const [switchingAction, setSwitchingAction] = useState<'switch' | 'disconnect' | null>(null); const switchingDevice = switchingAction !== null; const switchingRef = useRef(false); const activePeerId = peerContext?.peerMode.active ? peerContext.peerMode.deviceId : null; @@ -243,10 +247,12 @@ const DeviceStatusControl: React.FC = ({ // never a control target. This machine is never gated by its own flag. if (target.device_id !== availableTargets.localId && !isDeviceControllable(target)) return; switchingRef.current = true; - const alreadyConnected = peerContext.attachments.some(attachment => ( - attachment.deviceId === target.device_id && attachment.health === 'ready' - )); - setSwitchingAction(target.device_id === availableTargets.localId || alreadyConnected ? 'switch' : 'connect'); + setSwitchingAction('switch'); + // The workbench fades behind a curtain while it rebuilds for the target. + const intent = beginDeviceOpen({ + surfaceId: target.device_id === availableTargets.localId ? LOCAL_SURFACE_ID : surfaceIdForDevice(target.device_id), + key: `switch:${target.device_id}`, + }); try { if (target.device_id === availableTargets.localId) await peerContext.switchToLocal('manual'); else await peerContext.switchToDevice(target.device_id, resolveDeviceName(target.device_id, target.device_alias ?? target.device_name ?? target.device_id)); @@ -254,6 +260,7 @@ const DeviceStatusControl: React.FC = ({ } catch (error) { warning(error instanceof Error ? error.message : String(error)); } finally { + endDeviceOpen(intent); switchingRef.current = false; setSwitchingAction(null); } @@ -306,6 +313,8 @@ const DeviceStatusControl: React.FC = ({ }, [activePeerId, peerContext, warning]); const handleManageDevices = useCallback(() => { + // The dialog grows out of this card instead of replacing it. + rememberMorphOrigin(DEVICES_DIALOG_MORPH_KEY, popoverRef.current); onOpenChange(false); onManageDevices(); }, [onManageDevices, onOpenChange]); @@ -357,11 +366,29 @@ const DeviceStatusControl: React.FC = ({ return device.name; }, [t]); const displayedDevice = previewDevices[previewIndex] ?? overview.primaryDevice; + // The account device the card shows, when the directory lists it: only those + // can be renamed, and only through a Relay that supports device aliases. + const displayedAccountDevice = availableTargets?.devices.find(device => device.device_id === carouselDeviceIds[previewIndex]); + const aliasSupported = useRelayDeviceAliasSupport(open && Boolean(availableTargets)); + const [aliasEdit, setAliasEdit] = useState<{ deviceId: string; draft: string; saving: boolean } | null>(null); + useEffect(() => { if (!open) setAliasEdit(null); }, [open]); + const editingAlias = aliasEdit && aliasEdit.deviceId === displayedAccountDevice?.device_id ? aliasEdit : null; + const saveAlias = async () => { + if (!editingAlias || editingAlias.saving) return; + const { deviceId, draft } = editingAlias; + setAliasEdit({ ...editingAlias, saving: true }); + try { + await remoteConnectAPI.accountUpdateDevice(deviceId, draft.trim() || null); + await refreshDeviceDirectory(); + setAliasEdit(current => current?.deviceId === deviceId ? null : current); + } catch (error) { + setAliasEdit(current => current?.deviceId === deviceId ? { ...current, saving: false } : current); + notifyError(error instanceof Error && /unsupported|not found|unknown command|404|405/i.test(error.message) + ? t('accountLogin.deviceAliasUnsupported') : error instanceof Error ? error.message : String(error)); + } + }; const displayedActive = !displayedDevice.local && displayedDevice.activities.includes('current-use') && overview.peerActive; - const displayedConnected = peerContext?.attachments.some(attachment => ( - attachment.deviceId === carouselDeviceIds[previewIndex] && attachment.health === 'ready' - )) ?? false; const displayedIncompatible = previewIncompatible && !displayedActive; const displayedHasAction = displayedActive || isPreviewing || displayedIncompatible; const displayedControlState = displayedIncompatible ? 'incompatible' @@ -370,13 +397,14 @@ const DeviceStatusControl: React.FC = ({ ? previewIncompatibleNotice : displayedActive ? t('deviceOverview.disconnectActionHint') : displayedDevice.local ? t('deviceOverview.backToThisDevice') - : displayedConnected ? t('deviceOverview.switchUse') : t('deviceOverview.connectAndUse'); + // Attaching is an implementation detail: every device offers the same action. + : t('deviceOverview.switchUse'); const displayedIdleActionText = displayedIncompatible ? t('deviceOverview.unavailableAction') : displayedActive ? t('deviceOverview.disconnectAction') : displayedDevice.local ? t('deviceOverview.returnLocalAction') - : displayedConnected ? t('deviceOverview.switchUse') : t('deviceOverview.connectAndUse'); - const displayedActionText = switchingAction === 'connect' ? t('deviceOverview.connectingAction') - : switchingAction === 'switch' ? t('deviceOverview.switchingAction') + // Attaching is an implementation detail: every device offers the same action. + : t('deviceOverview.switchUse'); + const displayedActionText = switchingAction === 'switch' ? t('deviceOverview.switchingAction') : switchingAction === 'disconnect' ? t('deviceOverview.disconnectingAction') : displayedIdleActionText; @@ -552,9 +580,43 @@ const DeviceStatusControl: React.FC = ({ - - {displayedDevice.name} - + {editingAlias ? ( + + {/* The name itself becomes editable: same type, an underline + in the card's light, actions inside the same line. */} + setAliasEdit({ ...editingAlias, draft: event.target.value })} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); void saveAlias(); } + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setAliasEdit(null); } + }} + placeholder={displayedAccountDevice?.device_name ?? displayedDevice.name} + aria-label={t('accountLogin.deviceAlias')} + title={t('accountLogin.deviceAliasPlaceholder')} + autoFocus + onFocus={event => event.currentTarget.select()} + disabled={editingAlias.saving} + data-testid="nav-device-alias-input" + trailing={<> + } + aria-label={t('accountLogin.saveDeviceAlias')} loading={editingAlias.saving} + onClick={() => { void saveAlias(); }} data-testid="nav-device-alias-save" /> + } + aria-label={t('accountLogin.cancel')} disabled={editingAlias.saving} + onClick={() => setAliasEdit(null)} /> + } + /> + + ) : ( + + {displayedDevice.name} + + )} {displayedActive && ( = ({ data-testid="nav-device-usage-status" /> )} - {!displayedHasAction && displayedDevice.local && ( - - - + {!editingAlias && aliasSupported && displayedAccountDevice && ( + } + aria-label={t('accountLogin.editDeviceAlias')} + data-testid="nav-device-alias-edit" + onClick={() => setAliasEdit({ + deviceId: displayedAccountDevice.device_id, draft: displayedAccountDevice.device_alias ?? '', saving: false, + })} + /> )}
+ {/* This machine has no action: the same disc marks it as home. */} + {!displayedHasAction && displayedDevice.local && ( + + + + )} {displayedHasAction && (
- {/* One round action: the glyph says what it does, the tooltip and - the accessible name carry the words. */} + {/* One round action: the glyph says what it does and the accessible + name carries the words. */} = ({ ? : displayedActive ? : displayedDevice.local ? - : displayedConnected ? : } + : } aria-label={`${displayedDevice.name} · ${displayedActionText}`} - title={switchingDevice ? displayedActionText : displayedActionLabel} + // The glyph says what it does; only an incompatible device needs words. + title={displayedIncompatible ? displayedActionLabel : undefined} // An incompatible device stays focusable so its version notice // (the tooltip) is reachable; it simply has no action. disabled={!displayedIncompatible && ((!displayedActive && !isPreviewing) || !peerContext || switchingDevice || previewMotion.isTransitioning)} @@ -666,3 +742,17 @@ const DeviceStatusControl: React.FC = ({ }; export default DeviceStatusControl; + +/** Whether the Relay renames devices (`device_alias_v1`), asked when the card opens. */ +function useRelayDeviceAliasSupport(enabled: boolean): boolean { + const [supported, setSupported] = useState(false); + useEffect(() => { + if (!enabled) return undefined; + let current = true; + void remoteConnectAPI.accountRelayCapabilities() + .then(capabilities => { if (current) setSupported(capabilities.includes('device_alias_v1')); }) + .catch(() => { if (current) setSupported(false); }); + return () => { current = false; }; + }, [enabled]); + return supported; +} diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 1ed105acd5..b9858876eb 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -104,6 +104,8 @@ const PersistentFooterActions: React.FC = () => { if (nextOpen && menuOpen) { closeMenu(); } + // The dialog grows out of the card: have its code ready before it is asked for. + if (nextOpen) void import('../../RemoteConnectDialog'); setDeviceOverviewOpen(nextOpen); }, [closeMenu, menuOpen]); diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx index 369c5b892b..adacaf0aad 100644 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx @@ -1,3 +1,5 @@ +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { RotateCcw } from 'lucide-react'; @@ -295,6 +297,11 @@ const WorkspaceSessionFilterMenu: React.FC = () => { { flowChatStore.clearAllSessionUnreadCompletions(); + // The sidebar lists every device: mark theirs read on their hosts too. + const rendered = getActiveSurfaceId(); + for (const surfaceId of deviceWorkspaceCatalog.loadedSurfaces()) { + if (surfaceId !== rendered) void deviceWorkspaceCatalog.markAllRead(surfaceId); + } close(); }} > diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx new file mode 100644 index 0000000000..0e559866c2 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})); +const catalogState = vi.hoisted(() => ({ snapshot: { status: 'ready', sessions: {} as Record } })); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: () => catalogState.snapshot, + subscribe: () => () => undefined, + loadMoreSessions: vi.fn(), + }, +})); +vi.mock('@/flow_chat/services/workspaceSessionTotals', async importOriginal => ({ + ...await importOriginal(), + knownWorkspaceSessionTotal: () => 8, +})); + +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; +import { BackgroundSessionList } from './BackgroundSessionList'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot: SidebarDeviceSlot = { + surfaceId: 'peer-list', deviceId: 'peer-list', name: 'Studio', isLocal: false, rendered: false, hostKind: 'desktop', controllable: true, +}; +const record = (sessionId: string, createdAt: number, lastFinishedAt: number) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt, lastActiveAt: lastFinishedAt, + lastFinishedAt, turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], workspaceId: 'ws', +}) as never; + +describe('BackgroundSessionList', () => { + let host: HTMLDivElement; + let root: Root; + const actions = { pendingKey: null, run: vi.fn() }; + const rows = () => [...host.querySelectorAll('[data-testid="nav-device-session-item"]')].map(row => row.getAttribute('data-session-id')); + const render = () => act(() => root.render()); + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + flowChatStore.seedSurfaceSessions('peer-list', [{ workspaceId: 'ws', workspacePath: '/w', sessions: [ + record('oldest', 1, 100), record('newest-finished', 2, 900), record('middle', 3, 500), + record('created-late', 9, 200), record('s5', 4, 300), record('s6', 5, 50), + ] }]); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + flowChatStore.discardSurfaceState('peer-list'); + flowChatStore.discardSurfaceState('peer-empty'); + catalogState.snapshot = { status: 'ready', sessions: {} }; + useWorkspaceSessionViewStore.getState().setOrdering('updated'); + }); + + it('orders by the live list rules and shows its first level', () => { + useWorkspaceSessionViewStore.getState().setOrdering('updated'); + render(); + expect(rows()).toEqual(['newest-finished', 'middle', 's5']); + const toggle = host.querySelector('[data-testid="nav-device-session-list-toggle"]'); + expect(toggle?.getAttribute('data-session-nav-toggle-action')).toBe('show-more'); + expect(toggle?.textContent).toContain('+5'); + }); + + it('follows the ordering preference', () => { + useWorkspaceSessionViewStore.getState().setOrdering('created'); + render(); + expect(rows()).toEqual(['created-late', 's6', 's5']); + }); + + it('expands like the live list', () => { + render(); + act(() => host.querySelector('[data-testid="nav-device-session-list-toggle"]')!.click()); + expect(rows()).toHaveLength(6); + }); + + it('limits a flat device list to three rows with show more', () => { + act(() => root.render()); + expect(rows()).toHaveLength(3); + expect(host.querySelector('[data-testid="nav-device-session-list-toggle"]')).not.toBeNull(); + }); + + it('labels flat rows with their workspace like the live list', () => { + act(() => root.render()); + const names = [...host.querySelectorAll('.openbitfun-nav-panel__inline-item-workspace-name')].map(node => node.textContent); + expect(names).toEqual(['OpenBitFun', 'OpenBitFun', 'OpenBitFun']); + }); + + describe('with no rows in the container', () => { + const emptySlot = { ...slot, surfaceId: 'peer-empty', deviceId: 'peer-empty' }; + const renderEmpty = () => act(() => root.render()); + const page = (sessions: unknown[]) => ({ + status: 'ready', sessions, records: sessions, activities: {}, hasMore: true, total: 8, limit: 8, + }); + const empty = () => host.querySelector('.openbitfun-nav-panel__inline-empty'); + const loadingRow = () => host.querySelector('.openbitfun-nav-panel__inline-loading'); + const toggle = () => host.querySelector('[data-testid="nav-device-session-list-toggle"]'); + + it('shows progress, not "no sessions" under a known total, while the rows the catalog lists are not drawn yet', () => { + catalogState.snapshot = { status: 'ready', sessions: { ws: page([record('listed', 1, 1)]) } }; + renderEmpty(); + expect(empty()).toBeNull(); + expect(loadingRow()).not.toBeNull(); + expect(toggle()).toBeNull(); + }); + + it('treats a workspace page not read yet as loading', () => { + renderEmpty(); + expect(empty()).toBeNull(); + expect(loadingRow()).not.toBeNull(); + }); + + it('says "no sessions" once the host listed none', () => { + catalogState.snapshot = { status: 'ready', sessions: { ws: { ...page([]), hasMore: false, total: 0 } } }; + renderEmpty(); + expect(empty()).not.toBeNull(); + expect(loadingRow()).toBeNull(); + }); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx new file mode 100644 index 0000000000..c7d08069f2 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx @@ -0,0 +1,368 @@ +/** + * Session rows of a device the window is not rendering (or is still switching + * to), drawn from that device's own FlowChat container. + * + * Everything that decides which rows appear and in what order is the live + * list's: the same hierarchy, comparator, view filters, first-level limit and + * "show more" rule. A device therefore looks identical before, during and + * after a switch, and the hand-over to the live list moves nothing. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { Icon, IconButton, Input, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; +import { Loader2 } from 'lucide-react'; +import { useI18n, i18nService } from '@/infrastructure/i18n'; +import { surfaceScopedKey, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { sessionNavStatusService } from '@/flow_chat/services/sessionNavStatusService'; +import { knownWorkspaceSessionTotal } from '@/flow_chat/services/workspaceSessionTotals'; +import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { + compareWorkspaceNavSessions, + matchesWorkspaceSessionView, + matchesWorkspaceSourceAgent, + sessionSourceAgentLabel, + useWorkspaceSessionViewStore, + workspaceSessionViewKey, +} from '../../workspaceSessionView'; +import { getWorkspaceSessionHierarchy } from '../sessions/sessionNavHierarchy'; +import { + getSessionDisplayLimit, + getSessionExpandToggleState, + nextSessionDisplayCount, + SESSIONS_PREFETCH_COUNT, +} from '../sessions/sessionNavExpand'; +import { useSessionNavExpansion } from '../sessions/useSessionNavExpansion'; +import { isWorkspaceSessionsCollapsed } from '../workspaces/workspaceDisclosure'; +import { SessionStatusMark } from '../sessions/SessionStatusIndicator'; +import { useCatalogSessionStatus } from './catalogSessionStatus'; +import { openDeviceSession } from './openOnDevice'; +import { useSurfaceSessions } from './useSurfaceSessions'; +import { getChildSessionBadge } from '../sessions/sessionRowBadges'; +import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; +import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; +import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; +import { sessionBelongsToWorkspaceNavRow } from '@/flow_chat/utils/sessionOrdering'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; +import { CatalogSessionMenu, catalogSessionActionKey, runCatalogSessionAction } from './CatalogSessionMenu'; + +export interface BackgroundActions { + pendingKey: string | null; + run: (key: string, action: () => Promise, failureKey: string, extra?: { sessionId?: string; title?: string }) => void; +} + +const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); + +function useCatalogActivities(surfaceId: DeviceSurfaceId, workspaceIds: readonly string[]) { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId), [surfaceId]); + const snapshot = useSyncExternalStore(subscribe, read, read); + return useMemo(() => { + const activities: Record = {}; + let loading = false; + let hasMore = false; + let limit = 0; + /** Top-level sessions the host listed for these workspaces. */ + const listed: string[] = []; + for (const workspaceId of workspaceIds) { + const page = snapshot.sessions[workspaceId]; + // Not read yet: its rows are still coming, unless the device cannot be read. + if (!page || page.status === 'idle') { + loading ||= snapshot.status !== 'error'; + continue; + } + Object.assign(activities, page.activities); + loading ||= page.status === 'loading' && page.records.length === 0; + hasMore ||= page.hasMore; + limit = Math.max(limit, page.limit); + for (const session of page.sessions) listed.push(session.sessionId); + } + return { activities, loading, hasMore, limit, listed }; + }, [snapshot, workspaceIds]); +} + +const BackgroundSessionRow: React.FC<{ + slot: SidebarDeviceSlot; + session: Session; + level: 0 | 1; + activity?: SessionActivitySummary; + actions: BackgroundActions; + flat: boolean; + /** Owning workspace name, shown in the flat view like the live row. */ + workspaceName?: string; + titleNumber?: string; + /** Owning workspace name for menu copy (archived delete confirmation). */ + ownerWorkspaceName: string; +}> = ({ slot, session, level, activity, actions, flat, workspaceName, titleNumber, ownerWorkspaceName }) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + const [menuOpen, setMenuOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(''); + const editInputRef = useRef(null); + const status = useCatalogSessionStatus(slot.surfaceId, session.sessionId, activity); + const title = getTitle(session); + const relationship = resolveSessionRelationship(session); + const isChildSession = level === 1 && relationship.displayAsChild; + const key = `session:${session.sessionId}`; + const opening = actions.pendingKey === key; + // A menu action renders the device too; the row shows the same progress. + const acting = actions.pendingKey?.startsWith(catalogSessionActionKey(session.sessionId, '')) ?? false; + const workspaceId = session.workspaceId ?? session.config.workspaceId ?? ''; + useEffect(() => { + if (!editing) return; + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, [editing]); + const startRename = () => { + setEditingTitle(title); + setEditing(true); + }; + const cancelRename = () => { + setEditing(false); + setEditingTitle(''); + }; + const confirmRename = () => { + if (!editing) return; + const trimmed = editingTitle.trim(); + cancelRename(); + if (!trimmed || trimmed === title) return; + runCatalogSessionAction(actions, peer, slot, session.sessionId, { kind: 'rename', title: trimmed }, t); + }; + const open = () => editing ? undefined : actions.run(key, () => openDeviceSession(peer, slot, workspaceId, { + sessionId: session.sessionId, + } as SessionMetadata, title), 'nav.sessions.openFailed', { sessionId: session.sessionId, title }); + return ( +
{ + // Keys typed in the row's own controls (menu button, rename field) stay theirs. + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }} + > + {editing ? ( +
event.stopPropagation()}> + setEditingTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); confirmRename(); } + else if (event.key === 'Escape') { event.preventDefault(); cancelRename(); } + }} + onBlur={confirmRename} + size="sm" + data-testid="nav-device-session-rename-input" + /> + + { event.stopPropagation(); confirmRename(); }} + icon={} + /> + + + { event.preventDefault(); event.stopPropagation(); cancelRename(); }} + icon={} + /> + +
+ ) : <> + + + + + {title} + + + {session.importedSource ? ( + + {sessionSourceAgentLabel(session.importedSource)} + + ) : null} + {isChildSession ? ( + + {getChildSessionBadge(relationship.kind)} + + ) : null} + {flat && workspaceName && !isChildSession ? ( + {workspaceName} + ) : null} + + + +
+ {opening || acting + ? + : } + +
+ } +
+ ); +}; + +/** + * `workspaceIds` holds one project for the grouped view, every project for the + * flat view (`flat`), mirroring the live `SessionsSection` scopes. + */ +export const BackgroundSessionList: React.FC<{ + slot: SidebarDeviceSlot; + workspaceIds: readonly string[]; + actions: BackgroundActions; + flat?: boolean; + /** Workspace id to name: the flat view's workspace labels and menu copy. */ + workspaceNames?: ReadonlyMap; +}> = ({ slot, workspaceIds, actions, flat = false, workspaceNames }) => { + const { t } = useI18n('common'); + const sessions = useSurfaceSessions(slot.surfaceId); + const titleNumbers = useSessionTitleNumbers(sessions); + const ordering = useWorkspaceSessionViewStore(state => state.ordering); + const show = useWorkspaceSessionViewStore(state => state.show); + const filters = useWorkspaceSessionViewStore(state => state.filters); + const sourceAgent = useWorkspaceSessionViewStore(state => state.sourceAgent); + const { activities, loading, hasMore, limit: loadedLimit, listed } = useCatalogActivities(slot.surfaceId, workspaceIds); + const primaryWorkspaceId = workspaceIds[0] ?? ''; + const { displayCount, setDisplayCount } = useSessionNavExpansion( + `${surfaceScopedKey(slot.surfaceId, flat ? undefined : primaryWorkspaceId)}:${sourceAgent}`, + // Unmounted by a collapse: reopening starts at the first rows. + { shouldHandOff: () => flat || !isWorkspaceSessionsCollapsed(slot.surfaceId, primaryWorkspaceId) }, + ); + + const isRunning = useCallback((session: Session) => ( + activities[session.sessionId]?.execution === 'running' + || sessionNavStatusService.isSurfaceRunning(slot.surfaceId, session.sessionId) + ), [activities, slot.surfaceId]); + + const { visibleItems, total } = useMemo(() => { + const { topLevelSessions, childrenByParent } = getWorkspaceSessionHierarchy( + show === 'archived' ? [...sessions.values()].filter(session => session.persistedStatus === 'archived') : sessions.values(), + workspaceIds, + ); + const compare = (left: Session, right: Session) => compareWorkspaceNavSessions( + left, right, ordering, getTitle, isRunning, + session => sessionNavStatusService.getSurfaceSortTimestamp(slot.surfaceId, session), + ); + const matches = (session: Session) => matchesWorkspaceSourceAgent(session, sourceAgent) + && matchesWorkspaceSessionView(session, show, filters, isRunning(session)); + const parents = [...topLevelSessions].sort(compare).filter(matches); + const limit = getSessionDisplayLimit({ + loadedTopLevelCount: parents.length, + displayCount, + // Each device's flat list shows its first rows plus "show more". + showAllWithoutLimit: false, + }); + const items: Array<{ session: Session; level: 0 | 1 }> = []; + for (const parent of parents.slice(0, limit)) { + items.push({ session: parent, level: 0 }); + for (const child of [...(childrenByParent.get(parent.sessionId) ?? [])].sort(compare).filter(matches)) { + items.push({ session: child, level: 1 }); + } + } + const reported = workspaceIds.length === 1 + ? knownWorkspaceSessionTotal(slot.surfaceId, primaryWorkspaceId, workspaceSessionViewKey(sourceAgent, show, filters)) : undefined; + return { visibleItems: items, total: Math.max(parents.length, reported ?? 0), shown: limit }; + }, [displayCount, filters, isRunning, ordering, primaryWorkspaceId, sessions, show, slot.surfaceId, sourceAgent, workspaceIds]); + + // "No sessions" only once the host said so. Rows the catalog lists that the + // container does not hold yet are on their way (seeding follows the catalog), + // and a page not read yet is loading, not empty: either way the list shows + // progress rather than an empty state under a known total. + const awaitingRows = loading || listed.some(sessionId => !sessions.has(sessionId)); + const toggle = getSessionExpandToggleState(total, displayCount); + const handleToggle = () => { + const next = nextSessionDisplayCount(displayCount, total); + setDisplayCount(next); + // Keep one step loaded ahead, like the live list. + const wanted = next + SESSIONS_PREFETCH_COUNT; + if (wanted > loadedLimit && hasMore) { + for (const workspaceId of workspaceIds) void deviceWorkspaceCatalog.loadMoreSessions(slot.surfaceId, workspaceId, wanted); + } + }; + const remaining = toggle.action === 'show-more' ? toggle.remainingCount : null; + + return ( +
+ {visibleItems.map(({ session, level }) => { + const ownerId = workspaceIds.find(id => sessionBelongsToWorkspaceNavRow(session, id)) ?? ''; + const ownerName = workspaceNames?.get(ownerId); + return ( + + ); + })} + {visibleItems.length === 0 && (awaitingRows ? ( +
+
+ ) : ( +
{t('nav.sessions.noSessions')}
+ ))} + {/* Like the live list, "show more" belongs under rows, never under an empty or loading list. */} + {toggle.shouldRender && visibleItems.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.test.tsx new file mode 100644 index 0000000000..7a91205181 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})); +const catalogSnapshot = vi.hoisted(() => ({ sessions: {} })); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: () => catalogSnapshot, + subscribe: () => () => undefined, + loadMoreSessions: vi.fn(), + }, +})); +const device = vi.hoisted(() => ({ + runAction: vi.fn(async () => undefined), + openSession: vi.fn(async () => undefined), + activate: vi.fn(async () => 'activated'), +})); +vi.mock('./openOnDevice', () => ({ + runDeviceSessionAction: device.runAction, + openDeviceSession: device.openSession, + activateDeviceSlot: device.activate, +})); + +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { BackgroundSessionList, type BackgroundActions } from './BackgroundSessionList'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot: SidebarDeviceSlot = { + surfaceId: 'peer-menu', deviceId: 'peer-menu', name: 'Studio', isLocal: false, rendered: false, hostKind: 'desktop', os: null, controllable: true, +}; +const record = (sessionId: string, status = 'active') => ({ + sessionId, sessionName: `Chat ${sessionId}`, agentType: 'agentic', modelName: 'm', createdAt: 1, lastActiveAt: 1, + lastFinishedAt: 1, turnCount: 2, messageCount: 2, toolCallCount: 0, status, tags: [], workspaceId: 'ws', +}) as never; + +describe('device catalog session row menu', () => { + let host: HTMLDivElement; + let root: Root; + // Runs the action like the catalog's intent runner does. + const actions: BackgroundActions & { run: ReturnType } = { + pendingKey: null, + run: vi.fn((_key: string, action: () => Promise) => { void action(); }), + }; + const row = () => host.querySelector('[data-testid="nav-device-session-item"][data-session-id="s-1"]')!; + const menu = () => document.querySelector('[data-testid="nav-device-session-menu"]'); + const openMenu = async () => { + await act(async () => row().querySelector('[data-testid="nav-device-session-menu-btn"]')!.click()); + }; + + beforeEach(() => { + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + flowChatStore.seedSurfaceSessions('peer-menu', [{ workspaceId: 'ws', workspacePath: '/w', sessions: [record('s-1')] }]); + act(() => root.render()); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + flowChatStore.discardSurfaceState('peer-menu'); + }); + + it('exposes the live row more button with menu-button semantics', async () => { + const actionsCell = row().querySelector('.openbitfun-nav-panel__inline-item-trailing .openbitfun-nav-panel__inline-item-actions'); + const button = actionsCell?.querySelector('button.openbitfun-nav-panel__inline-item-action-btn'); + expect(button).not.toBeNull(); + expect(button!.getAttribute('aria-haspopup')).toBe('menu'); + expect(button!.getAttribute('aria-expanded')).toBe('false'); + expect(button!.getAttribute('aria-label')).toContain('actions.more'); + + await openMenu(); + expect(button!.getAttribute('aria-expanded')).toBe('true'); + expect(row().classList.contains('is-menu-open')).toBe(true); + expect(menu()?.classList.contains('openbitfun-nav-panel__inline-item-menu-popover')).toBe(true); + expect([...menu()!.querySelectorAll('[data-testid]')].map(item => item.getAttribute('data-testid'))).toEqual([ + 'nav-device-session-menu-rename', + 'nav-device-session-menu-duplicate', + 'nav-device-session-menu-export-markdown', + 'nav-device-session-menu-open-storage', + 'nav-device-session-menu-archive', + ]); + // Opening the menu is not an intent to open the session. + expect(device.openSession).not.toHaveBeenCalled(); + expect(actions.run).not.toHaveBeenCalled(); + }); + + it('disables storage reveal for another device with its reason', async () => { + await openMenu(); + const reveal = menu()!.querySelector('[data-testid="nav-device-session-menu-open-storage"]')!; + expect(reveal.disabled).toBe(true); + expect(reveal.getAttribute('title')).toBe('nav.sessions.storageDirectoryDesktopOnly'); + }); + + it('runs a chosen action on the owning device and closes the menu', async () => { + await openMenu(); + await act(async () => menu()!.querySelector('[data-testid="nav-device-session-menu-archive"]')!.click()); + expect(actions.run).toHaveBeenCalledWith('session-action:s-1:archive', expect.any(Function), 'nav.devices.sessionActionFailed'); + expect(device.runAction).toHaveBeenCalledWith(null, slot, 's-1', { kind: 'archive' }, expect.any(Function)); + expect(device.openSession).not.toHaveBeenCalled(); + expect(menu()).toBeNull(); + }); + + it('renames inline, then renders the device to apply the new title', async () => { + await openMenu(); + await act(async () => menu()!.querySelector('[data-testid="nav-device-session-menu-rename"]')!.click()); + const input = row().querySelector('[data-testid="nav-device-session-rename-input"]')!; + expect(input).not.toBeNull(); + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + setValue.call(input, 'New title'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); + expect(device.runAction).toHaveBeenCalledWith(null, slot, 's-1', { kind: 'rename', title: 'New title' }, expect.any(Function)); + // Enter in the field is not an Enter on the row. + expect(device.openSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.tsx new file mode 100644 index 0000000000..9fbecaab2c --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.tsx @@ -0,0 +1,279 @@ +/** + * The "more" menu of a session row of a device the window is not rendering. + * + * Same trigger, popover and items as the live row's menu (`SessionsSection`). + * Every item renders the owning device first and then runs the live handler + * on the rendered surface (`runDeviceSessionAction`). Items whose live owner is + * the rendered row itself (scheduled jobs) are left to the live menu. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { createOverlayPortal, Icon, Menu, MenuItem, MenuSeparator, subscribeOverlayInteraction } from '@openbitfun/ui'; +import { Archive, FolderGit2, FolderOpen, Loader2 } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { beginDeviceOpen, endDeviceOpen, useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { isTauriRuntime } from '@/infrastructure/runtime/environment'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { isSessionEmptyForCopy } from '@/flow_chat/utils/sessionCopy'; +import { isWorktreeIsolatedSession } from '@/flow_chat/utils/sessionOrdering'; +import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree'; +import { useSideAnchoredPopoverPosition } from '@/shared/utils/useSideAnchoredPopoverPosition'; +import ArchivedSessionMenuItems from '../sessions/ArchivedSessionMenuItems'; +import { activateDeviceSlot, runDeviceSessionAction, type DeviceSessionAction } from './openOnDevice'; +import type { BackgroundActions } from './BackgroundSessionList'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +/** Intent key of a catalog session action; the row shows progress while it runs. */ +export const catalogSessionActionKey = (sessionId: string, kind: string) => `session-action:${sessionId}:${kind}`; + +const FAILURE_KEYS: Partial> = { + duplicate: 'nav.sessions.duplicateFailed', + 'open-worktree': 'nav.sessions.openWorktreeWorkspaceFailed', + 'reveal-storage': 'nav.sessions.openStorageDirectoryFailed', +}; + +export function runCatalogSessionAction( + actions: BackgroundActions, + peer: ReturnType, + slot: SidebarDeviceSlot, + sessionId: string, + action: DeviceSessionAction, + t: (key: string, options?: Record) => string, +): void { + actions.run( + catalogSessionActionKey(sessionId, action.kind), + () => runDeviceSessionAction(peer, slot, sessionId, action, t), + FAILURE_KEYS[action.kind] ?? 'nav.devices.sessionActionFailed', + ); +} + +export const CatalogSessionMenu: React.FC<{ + slot: SidebarDeviceSlot; + session: Session; + title: string; + workspaceName: string; + /** The host reports a running turn: not a stable copy boundary. */ + running: boolean; + actions: BackgroundActions; + open: boolean; + onOpenChange: (open: boolean) => void; + onRename: () => void; +}> = ({ slot, session, title, workspaceName, running, actions, open, onOpenChange, onRename }) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const [exportScopeMenu, setExportScopeMenu] = useState(false); + const position = useSideAnchoredPopoverPosition({ + open, + anchorRef, + popoverRef, + gap: 4, + layoutRevision: `${open}:${exportScopeMenu}`, + }); + + const close = () => { + setExportScopeMenu(false); + onOpenChange(false); + }; + + useEffect(() => { + if (!open) return; + const handleOutside = (event: MouseEvent) => { + if (!popoverRef.current?.contains(event.target as Node) && !anchorRef.current?.contains(event.target as Node)) close(); + }; + const remove = subscribeOverlayInteraction(popoverRef, 'mousedown', handleOutside); + return () => remove?.(); + // `close` only reads stable setters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const run = (event: React.MouseEvent, action: DeviceSessionAction) => { + event.stopPropagation(); + close(); + runCatalogSessionAction(actions, peer, slot, session.sessionId, action, t); + }; + + const isArchived = session.persistedStatus === 'archived'; + const isDispatched = isNonLocalDispatchTarget(session.config.dispatchTarget); + const capabilities = slot.isLocal + ? null + : peer?.attachments.find(attachment => attachment.deviceId === slot.deviceId)?.capabilities ?? null; + // An unanswered host is re-checked after its device is rendered. + const copyUnsupported = !slot.isLocal && capabilities !== null && capabilities.wholeSessionCopyV1 !== true; + const copyEmpty = isSessionEmptyForCopy(session); + const worktreeRootPath = isWorktreeIsolatedSession(session) ? sessionWorktreeRootPath(session) ?? '' : ''; + // Storage directories open only for data stored on this desktop instance. + const storageUnavailable = !slot.isLocal || !isTauriRuntime() || isDispatched; + const busy = actions.pendingKey !== null; + + // Archived rows reuse the live archived menu; it renders the device itself. + const activateForArchived = async (): Promise => { + if (useDeviceOpenIntent.getState().intent) return false; + const intent = beginDeviceOpen({ surfaceId: slot.surfaceId, key: catalogSessionActionKey(session.sessionId, 'archived') }); + try { + return await activateDeviceSlot(peer, slot) === 'activated'; + } finally { + endDeviceOpen(intent); + } + }; + + return ( + <> +
+ +
+ {open && createOverlayPortal( + event.stopPropagation()} + > + {isArchived ? ( + undefined} + /> + ) : exportScopeMenu ? ( + <> + } + onClick={event => { event.stopPropagation(); setExportScopeMenu(false); }} + data-testid="nav-device-session-menu-export-back" + > + {t('nav.sessions.exportMarkdown')} + + } + onClick={event => run(event, { kind: 'export', scope: 'full', title })} + data-testid="nav-device-session-menu-export-full" + > + {t('nav.sessions.exportMarkdownFull')} + + } + onClick={event => run(event, { kind: 'export', scope: 'result', title })} + data-testid="nav-device-session-menu-export-result" + > + {t('nav.sessions.exportMarkdownResult')} + + + ) : ( + <> + } + onClick={event => { event.stopPropagation(); close(); onRename(); }} + data-testid="nav-device-session-menu-rename" + > + {t('nav.sessions.rename')} + + + : } + onClick={event => run(event, { kind: 'duplicate' })} + disabled={isDispatched || copyUnsupported || copyEmpty || running} + title={isDispatched + ? t('nav.sessions.duplicateDispatchUnsupported') + : copyUnsupported + ? t('nav.sessions.duplicateHostUnsupported') + : copyEmpty + ? t('nav.sessions.duplicateEmpty') + : running + ? t('nav.sessions.duplicateRunning') + : undefined} + data-testid="nav-device-session-menu-duplicate" + > + {t('nav.sessions.duplicate')} + + } + onClick={event => { event.stopPropagation(); setExportScopeMenu(true); }} + data-testid="nav-device-session-menu-export-markdown" + > + {t('nav.sessions.exportMarkdown')} + + {worktreeRootPath ? ( + } + onClick={event => run(event, { kind: 'open-worktree', worktreePath: worktreeRootPath })} + data-testid="nav-device-session-menu-open-worktree-workspace" + > + {t('nav.sessions.openWorktreeWorkspace')} + + ) : null} + + } + onClick={event => run(event, { kind: 'reveal-storage' })} + disabled={storageUnavailable} + title={storageUnavailable ? t('nav.sessions.storageDirectoryDesktopOnly') : undefined} + data-testid="nav-device-session-menu-open-storage" + > + {t('nav.sessions.openStorageDirectory')} + + } + onClick={event => run(event, { kind: 'archive' })} + data-testid="nav-device-session-menu-archive" + > + {t('nav.sessions.archive')} + + + )} + , + getAppearanceOverlayHost(), + )} + + ); +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx new file mode 100644 index 0000000000..fab3fde22d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock('@/infrastructure/peer-device/useDeviceWorkspaceCatalog', () => ({ + useDeviceWorkspaceCatalog: () => ({ + status: 'ready', live: true, sessions: {}, + workspaces: [ + { id: 'assistant', name: 'Fu', rootPath: '/a', workspaceKind: 'assistant', tags: [], languages: [] }, + { id: 'project', name: 'OpenBitFun', rootPath: '/p', workspaceKind: 'normal', tags: [], languages: [] }, + ], + }), +})); +vi.mock('./BackgroundSessionList', () => ({ + BackgroundSessionList: () =>
, +})); +const open = vi.hoisted(() => ({ action: vi.fn(async () => undefined), resources: vi.fn(async () => undefined) })); +vi.mock('./openOnDevice', () => ({ + createDeviceSession: vi.fn(async () => undefined), + openDeviceAssistant: vi.fn(async () => undefined), + openDeviceWorkspaceAction: open.action, + openDeviceProjectResources: open.resources, +})); + +import DeviceCatalogSection from './DeviceCatalogSection'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot = { surfaceId: 'peer', deviceId: 'peer', name: 'Win', isLocal: false, rendered: false, hostKind: 'desktop' as const, os: 'Windows', controllable: true }; + +describe('DeviceCatalogSection rows', () => { + let host: HTMLDivElement; + let root: Root; + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + it('keeps a project row actions inside its header card, never next to session rows', () => { + const project = host.querySelector('[data-workspace-id="project"]')!; + const card = project.querySelector('.openbitfun-nav-panel__workspace-item-card')!; + const actions = project.querySelector('.openbitfun-nav-panel__workspace-item-actions')!; + expect(card.contains(actions)).toBe(true); + expect(actions.closest('[data-testid="session-list"]')).toBeNull(); + expect([...actions.querySelectorAll('button')].map(button => button.getAttribute('data-testid'))).toEqual([ + 'nav-device-workspace-menu-btn', 'nav-device-project-resources-btn', 'nav-device-new-session-btn', + ]); + }); + + it('offers the live rows actions and runs them on the owning device', async () => { + const project = host.querySelector('[data-workspace-id="project"]')!; + await act(async () => { project.querySelector('[data-testid="nav-device-workspace-menu-btn"]')!.click(); }); + expect(open.action).toHaveBeenCalledWith(null, slot, 'project', 'menu'); + const assistant = host.querySelector('[data-workspace-id="assistant"]')!; + expect(assistant.querySelector('[data-testid="nav-device-workspace-menu-btn"]')).not.toBeNull(); + expect(assistant.querySelector('[data-testid="nav-device-assistant-history-btn"]')).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx new file mode 100644 index 0000000000..d4fedea73f --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx @@ -0,0 +1,334 @@ +/** + * Workspaces and sessions of a device this window is not rendering. + * + * Rows mirror the live workspace list (same classes, ordering and grouping). + * The data comes from the read-only device catalog; acting on a row renders + * that device first and then runs the local handler (see `openOnDevice`). + */ + +import React, { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { ActionItem, Button, Icon, OverflowText, Tooltip } from '@openbitfun/ui'; +import { History, Loader2, MessageCirclePlus, Settings2 } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +import { peerReplyLostMessage } from '@/infrastructure/peer-device/peerReplyLost'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { deviceWorkspaceCatalog, type DeviceCatalogSnapshot } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; +import { useDeviceWorkspaceCatalog } from '@/infrastructure/peer-device/useDeviceWorkspaceCatalog'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { + getWorkspaceDisplayOrderRevision, + orderWorkspacesForDisplay, + subscribeWorkspaceDisplayOrder, +} from '@/infrastructure/services/business/workspaceDisplayOrder'; +import { WorkspaceIconArt } from '@/app/components/WorkspaceIcon/WorkspaceIcon'; +import { AssistantAvatar } from '@/app/components/AssistantAvatar'; +import { notificationService } from '@/shared/notification-system'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { resolveAssistantName } from '@/shared/utils/assistantName'; +import { projectWorkspaceBackedSessionGroups } from '../../sessionNavigationProjection'; +import { remoteWorkspaceBadge } from '../workspaces/remoteWorkspaceBadge'; +import { createDeviceSession, openDeviceAssistant, openDeviceProjectResources, openDeviceWorkspaceAction } from './openOnDevice'; +import { beginDeviceOpen, endDeviceOpen, useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; +import { BackgroundSessionList, type BackgroundActions } from './BackgroundSessionList'; +import { setWorkspaceSessionsCollapsed, useWorkspaceSessionsCollapsed } from '../workspaces/workspaceDisclosure'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; + +type CatalogActions = BackgroundActions; + +/** + * Opening an entry renders another device first, which takes a moment. The + * intent is global so the clicked row stays highlighted with progress for the + * whole open, other owners (catalog refresh, scene reset, list hand-over) can + * wait for it, and a second click is ignored. + */ +function useCatalogActions(surfaceId: string): CatalogActions { + const { t } = useI18n('common'); + const intent = useDeviceOpenIntent(state => state.intent); + const run = useCallback((key, action, failureKey, extra) => { + if (useDeviceOpenIntent.getState().intent) return; + const next = beginDeviceOpen({ surfaceId, key, ...extra }); + void action().catch(error => { + if (isSurfaceChangedError(error)) return; + notificationService.error(peerReplyLostMessage(error, t) + ?? (error instanceof Error && error.message ? error.message : t(failureKey)), { duration: 4000 }); + }).finally(() => endDeviceOpen(next)); + }, [surfaceId, t]); + return { pendingKey: intent?.surfaceId === surfaceId ? intent.key : null, run }; +} + +const CatalogAssistantRow: React.FC<{ slot: SidebarDeviceSlot; workspace: WorkspaceInfo; actions: CatalogActions }> = ({ + slot, workspace, actions, +}) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + const name = resolveAssistantName(workspace); + const key = `assistant:${workspace.id}`; + const open = () => actions.run(key, () => openDeviceAssistant(peer, slot, workspace.id, () => { + notificationService.info(t('nav.sessions.defaultUnavailable')); + }), 'nav.sessions.openFailed'); + return ( +
+
+ + + { event.stopPropagation(); open(); }}> + {name} + + +
event.stopPropagation()}> + + + + + + + +
+
+
+ ); +}; + +const CatalogProjectRow: React.FC<{ slot: SidebarDeviceSlot; workspace: WorkspaceInfo; actions: CatalogActions; single: boolean }> = ({ + slot, workspace, actions, single, +}) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + // The live row reads the same window-wide disclosure, so a switch keeps it. + const expanded = !useWorkspaceSessionsCollapsed(slot.surfaceId, workspace.id); + const remote = workspace.workspaceKind === WorkspaceKind.Remote; + const badge = remote ? remoteWorkspaceBadge(workspace) : undefined; + const toggle = () => setWorkspaceSessionsCollapsed(slot.surfaceId, workspace.id, expanded); + const workspaceIds = useMemo(() => [workspace.id], [workspace.id]); + const workspaceNames = useMemo(() => new Map([[workspace.id, workspace.name]]), [workspace.id, workspace.name]); + return ( +
+
+ +
+
+
+ + { event.stopPropagation(); toggle(); }}> + + {workspace.name} + + + + {badge && ( + + {badge.label} + + )} +
+
+
+ {/* Inside the card, like the live row, so the actions centre on it. */} +
event.stopPropagation()}> +
+ + + + + + + +
+
+
+ {expanded && ( +
+
+ +
+
+ )} +
+ ); +}; + +/** + * Attach the control link while the pointer is over a device's list, so a + * click only has to render it. Attaching neither renders nor mutates the host, + * and the link is kept for later switches anyway. + */ +const PREWARM_HOVER_MS = 200; +let prewarmTimer: ReturnType | null = null; + +function prewarmDeviceLink(slot: SidebarDeviceSlot): void { + if (slot.isLocal || !slot.deviceId || !slot.controllable || peerConnectionManager.has(slot.deviceId)) return; + cancelPrewarm(); + const deviceId = slot.deviceId; + // Only a pointer that rests on the list attaches; passing over it does not. + prewarmTimer = setTimeout(() => { + prewarmTimer = null; + if (!peerConnectionManager.has(deviceId)) void peerConnectionManager.connect(deviceId, slot.name).catch(() => undefined); + }, PREWARM_HOVER_MS); +} + +function cancelPrewarm(): void { + if (prewarmTimer !== null) clearTimeout(prewarmTimer); + prewarmTimer = null; +} + +/** + * Workspaces of a background device, in the device's display order: the + * device catalog once it has answered (an empty or failed read is shown as + * such), and until then the list this window last loaded for it, so the first + * read draws no loading row where the list already stood. + * + * Both lists are drawn in the order the device is shown now, not the order + * they were read in: the catalog of a device is not re-read while the window + * renders it, so its last read can predate an order the live list settled on, + * and the hand-over between the two projections must move no row. + */ +function useBackgroundWorkspaces(slot: SidebarDeviceSlot, catalog: DeviceCatalogSnapshot): readonly WorkspaceInfo[] { + const answered = catalog.status === 'ready' || catalog.status === 'error'; + const orderRevision = useSyncExternalStore(subscribeWorkspaceDisplayOrder, getWorkspaceDisplayOrderRevision); + return useMemo( + () => { + void orderRevision; + return answered + ? orderWorkspacesForDisplay(slot.surfaceId, catalog.workspaces) + : workspaceManager.getSurfaceOpenedWorkspaces(slot.surfaceId); + }, + [answered, catalog.workspaces, orderRevision, slot.surfaceId], + ); +} + +/** Body of a background (or still-switching) device section. Mirrors `WorkspaceListSection`'s structure. */ +const DeviceCatalogSection: React.FC<{ slot: SidebarDeviceSlot }> = ({ slot }) => { + const { t } = useI18n('common'); + const catalog = useDeviceWorkspaceCatalog(slot.surfaceId, slot.controllable); + const workspaces = useBackgroundWorkspaces(slot, catalog); + const actions = useCatalogActions(slot.surfaceId); + const grouping = useWorkspaceSessionViewStore(state => state.grouping); + const groups = useMemo(() => projectWorkspaceBackedSessionGroups(workspaces, 'all'), [workspaces]); + const assistants = groups.filter(group => group.kind === 'assistant'); + const projects = groups.filter(group => group.kind === 'project'); + const projectIds = useMemo(() => projects.map(group => group.workspace.id), [projects]); + const projectNames = useMemo(() => new Map(projects.map(group => [group.workspace.id, group.workspace.name])), [projects]); + + if (!slot.controllable) { + return
+ {t('deviceOverview.deviceClientIncompatible')} +
; + } + return ( +
prewarmDeviceLink(slot)} + onPointerLeave={cancelPrewarm} + aria-busy={catalog.status === 'loading' || undefined}> + {catalog.status === 'loading' && workspaces.length === 0 && ( +
+
+ )} + {catalog.status === 'ready' && workspaces.length === 0 && ( +
{t('nav.workspaces.emptySessionGroups')}
+ )} + {catalog.error && workspaces.length === 0 && ( + + )} + {assistants.map(group => ( +
+ +
+ ))} + {projects.length > 0 && ( +
+
+ {grouping === 'all' ? ( +
+ +
+ ) : projects.map(group => ( +
+ +
+ ))} +
+
+ )} +
+ ); +}; + +export default DeviceCatalogSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts new file mode 100644 index 0000000000..8c72bec785 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const navDeviceSectionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nav-device-section', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'body' }], + states: [ + { id: 'rendered', selector: { kind: 'self', suffix: '[data-openbitfun-state~="rendered"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-openbitfun-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss new file mode 100644 index 0000000000..1ba93d5ccd --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss @@ -0,0 +1,112 @@ +/** + * Device sections of the session list. Rows inside a section reuse the + * workspace and session row styles; only the device header is new. + */ + +.openbitfun-nav-panel { + &__device-sections { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__device-section { + display: flex; + flex-direction: column; + min-width: 0; + + & + & { + margin-top: var(--openbitfun-space-4); + } + } + + // A group title: same size as workspace names but muted and semibold, with + // air above it. Workspace rows stay the brightest text in the list. + &__device-header { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 28px; + // The name starts where the assistant avatar does: the assistant row's + // padding (space-1), its border (1px) and the open button's inset (4px). + margin: 0 var(--openbitfun-space-1) 0 calc(var(--openbitfun-space-1) + 1px); + padding: 0 var(--openbitfun-space-2) 0 4px; + border: none; + border-radius: var(--openbitfun-radius-base); + background: transparent; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + text-align: start; + cursor: pointer; + transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + &:hover { + color: var(--openbitfun-color-content-primary); + } + + &:focus-visible { + outline: 2px solid var(--openbitfun-color-accent-default); + outline-offset: -2px; + } + + .openbitfun-nav-panel__device-section:not(.is-expanded) > & { + opacity: 0.7; + } + } + + &__device-name { + flex: 0 1 auto; + min-width: 0; + } + + // A quiet capsule: muted fill with text in the sidebar's own color, so it + // reads below the device name in every theme. + &__device-pill { + flex: 0 0 auto; + padding: 0 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 55%, transparent); + color: var(--openbitfun-color-surface-chrome); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: 16px; + letter-spacing: normal; + } + + // The rendered-device mark matches the capsule's weight, not the accent. + &__device-current { + flex: 0 0 auto; + inline-size: 5px; + block-size: 5px; + border-radius: 50%; + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 75%, transparent); + } + + &__device-spinner-wrap { + display: inline-flex; + flex: 0 0 auto; + margin-inline-start: auto; + } + + &__device-spinner { + flex: 0 0 auto; + color: var(--openbitfun-color-content-secondary); + opacity: var(--openbitfun-opacity-muted); + animation: openbitfun-nav-session-spin 1s linear infinite; + } + + &__device-body { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__device-note { + margin: 0 var(--openbitfun-space-1); + padding: var(--openbitfun-space-1) var(--openbitfun-space-2); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx new file mode 100644 index 0000000000..5ef8bb2ee8 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const slots = vi.hoisted(() => ({ value: [] as unknown[] })); +const live = vi.hoisted(() => ({ loading: false, opened: [] as unknown[] })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('./useSidebarDevices', async importOriginal => ({ + ...await importOriginal(), + useSidebarDevices: () => slots.value, +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useWorkspaceContext: () => ({ loading: live.loading, openedWorkspacesList: live.opened }) })); +vi.mock('../workspaces/WorkspaceListSection', () => ({ default: () =>
})); +vi.mock('./DeviceCatalogSection', () => ({ + default: ({ slot }: { slot: { surfaceId: string } }) =>
, +})); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: () => ({ status: 'ready', workspaces: [{ id: 'w' }], sessions: {} }), + subscribe: () => () => undefined, + retainActivity: () => () => undefined, + }, +})); +vi.mock('@/flow_chat/services/backgroundSurfaceSeeding', () => ({ installBackgroundSurfaceSeeding: () => () => undefined })); + +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import DeviceWorkspaceSections from './DeviceWorkspaceSections'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot = (surfaceId: string, rendered: boolean) => ({ + surfaceId, deviceId: surfaceId, name: surfaceId, isLocal: surfaceId === 'local', rendered, hostKind: 'desktop', controllable: true, +}); + +describe('DeviceWorkspaceSections hand-over', () => { + let host: HTMLDivElement; + let root: Root; + const render = () => act(() => root.render()); + const body = (surfaceId: string) => host.querySelector(`[data-device-surface="${surfaceId}"] .openbitfun-nav-panel__device-body > *`)?.getAttribute('data-testid'); + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + live.loading = false; + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + it('keeps every device on its background rows until the open settles, then hands over once', () => { + const intent = beginDeviceOpen({ surfaceId: 'peer-b', key: 'session:s', sessionId: 's' }); + slots.value = [slot('local', false), slot('peer-b', true)]; + render(); + expect(body('local')).toBe('background-list'); + expect(body('peer-b')).toBe('background-list'); + act(() => endDeviceOpen(intent)); + expect(body('peer-b')).toBe('live-list'); + expect(body('local')).toBe('background-list'); + }); + + it('waits for the rendered device to finish loading before showing its live rows', () => { + live.loading = true; + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + expect(body('local')).toBe('background-list'); + live.loading = false; + render(); + expect(body('local')).toBe('live-list'); + }); + + it('keeps the live rows while a workspace is activated in them', () => { + live.loading = true; + live.opened = [{ id: 'ws' }]; + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + expect(body('local')).toBe('live-list'); + live.loading = false; + live.opened = []; + }); + + it('keeps device sections in a fixed order whichever device is rendered', () => { + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + const order = () => [...host.querySelectorAll('[data-testid="nav-device-section"]')].map(node => node.getAttribute('data-device-surface')); + expect(order()).toEqual(['local', 'peer-b']); + slots.value = [slot('local', false), slot('peer-b', true)]; + render(); + expect(order()).toEqual(['local', 'peer-b']); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx new file mode 100644 index 0000000000..265b15fdc6 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx @@ -0,0 +1,198 @@ +/** + * The session list grouped by device. + * + * Exactly one device is rendered by the window (Peer Device Mode). Its section + * is the live `WorkspaceListSection`; every other online host of the account + * gets a section fed by the read-only device catalog, loaded only once the + * section is expanded. Sections keep a fixed order, so opening something on + * another device changes the rendered device without moving the list. + * + * With a single device the list renders exactly as before, without headers. + */ + +import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; +import { Icon, OverflowText } from '@openbitfun/ui'; +import { Loader2 } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceId, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import WorkspaceListSection from '../workspaces/WorkspaceListSection'; +import DeviceCatalogSection from './DeviceCatalogSection'; +import { useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { installBackgroundSurfaceSeeding } from '@/flow_chat/services/backgroundSurfaceSeeding'; +import { devicePlatform, useDeviceSectionDisclosure, useSidebarDevices, type SidebarDeviceSlot } from './useSidebarDevices'; +import './DeviceWorkspaceSections.scss'; + +interface DeviceWorkspaceSectionsProps { + scrollRootRef?: React.RefObject; +} + +function useCatalogHasWorkspaces(surfaceId: string): boolean { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId).workspaces.length > 0 + || workspaceManager.getSurfaceOpenedWorkspaces(surfaceId).length > 0, [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} + +function useCatalogLoading(surfaceId: string, rendered: boolean): boolean { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback( + () => !rendered && deviceWorkspaceCatalog.getSnapshot(surfaceId).status === 'loading', + [rendered, surfaceId], + ); + return useSyncExternalStore(subscribe, read, read); +} + +/** + * Hold the list's height and scroll offset while a cross-device open runs. + * Sections hand over between catalog and live rows during a switch; without + * the hold, a momentarily shorter list clamps the scroll position to the top. + */ +function useSwitchLayoutHold( + containerRef: React.RefObject, + scrollRootRef?: React.RefObject, +): void { + const switching = useDeviceOpenIntent(state => state.intent !== null); + const held = useRef<{ scrollTop: number; userScrolled: boolean } | null>(null); + useLayoutEffect(() => { + const container = containerRef.current; + const scrollRoot = scrollRootRef?.current; + if (!container) return; + if (switching) { + container.style.minHeight = `${container.offsetHeight}px`; + const hold = { scrollTop: scrollRoot?.scrollTop ?? 0, userScrolled: false }; + held.current = hold; + const onWheel = () => { hold.userScrolled = true; }; + scrollRoot?.addEventListener('wheel', onWheel, { passive: true }); + scrollRoot?.addEventListener('touchmove', onWheel, { passive: true }); + return () => { + scrollRoot?.removeEventListener('wheel', onWheel); + scrollRoot?.removeEventListener('touchmove', onWheel); + }; + } + const hold = held.current; + held.current = null; + if (!hold) return; + // Let the live rows settle, then restore the offset and release the floor. + let frame = 0; + let frames = 0; + const settle = () => { + if (scrollRoot && !hold.userScrolled && scrollRoot.scrollTop !== hold.scrollTop) scrollRoot.scrollTop = hold.scrollTop; + if (++frames < 20) frame = requestAnimationFrame(settle); + else container.style.minHeight = ''; + }; + frame = requestAnimationFrame(settle); + return () => { cancelAnimationFrame(frame); container.style.minHeight = ''; }; + }, [containerRef, scrollRootRef, switching]); +} + +const DeviceSection: React.FC<{ + slot: SidebarDeviceSlot; + scrollRootRef?: React.RefObject; +}> = ({ slot, scrollRootRef }) => { + const { t } = useI18n('common'); + const bodyId = useId(); + const explicit = useDeviceSectionDisclosure(state => state.expanded[slot.surfaceId]); + const setExpanded = useDeviceSectionDisclosure(state => state.setExpanded); + // Every device starts open: its sessions coexist with this machine's. + const expanded = explicit ?? true; + const opening = useDeviceOpenIntent(state => state.intent?.surfaceId === slot.surfaceId); + const loading = useCatalogLoading(slot.surfaceId, slot.rendered) || (opening && !slot.rendered); + // While the rendered device's live catalog hydrates after a switch, keep + // drawing its warm device catalog so the list neither empties nor jumps. + // Only a live list that has nothing yet is hydrating: activating a workspace + // also sets `loading`, and must not swap the list it is clicked in. + const { loading: liveLoading, openedWorkspacesList } = useWorkspaceContext(); + const liveHydrating = liveLoading && openedWorkspacesList.length === 0; + const hasCatalog = useCatalogHasWorkspaces(slot.surfaceId); + // During a cross-device open every device draws its background projection, + // which uses the live list's data and rules; the rendered device hands over + // to its live list once, after the open, so its reads never delay the open. + const switching = useDeviceOpenIntent(state => state.intent !== null); + const showLive = slot.rendered && ((!switching && !liveHydrating) || !hasCatalog); + // CLI hosts say so; client hosts name their platform. + const platform = devicePlatform(slot.os); + const hostTag = slot.hostKind === 'cli' ? t('nav.devices.hostCli') + : platform ? t(`nav.devices.platform.${platform}`) : null; + const toggleLabel = t(expanded ? 'nav.devices.collapse' : 'nav.devices.expand', { name: slot.name }); + + return ( +
+ + {expanded && ( +
+ {showLive + ? + : } +
+ )} +
+ ); +}; + +const DeviceWorkspaceSections: React.FC = ({ scrollRootRef }) => { + const { t } = useI18n('common'); + const slots = useSidebarDevices(t('deviceOverview.thisDevice')); + const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const setExpanded = useDeviceSectionDisclosure(state => state.setExpanded); + + // Every listed host keeps its session statuses current (debounced host + // invalidations), whether or not its section is open. The rendered device is + // kept warm too, so leaving it shows its catalog at once instead of a reload. + const backgroundSurfaces = slots.filter(slot => slot.controllable && slots.length > 1) + .map(slot => slot.surfaceId).join('\n'); + const multiDevice = slots.length > 1; + useEffect(() => (multiDevice ? installBackgroundSurfaceSeeding() : undefined), [multiDevice]); + useEffect(() => { + if (!backgroundSurfaces) return; + const releases = backgroundSurfaces.split('\n').map(surfaceId => deviceWorkspaceCatalog.retainActivity(surfaceId)); + return () => { for (const release of releases) release(); }; + }, [backgroundSurfaces]); + + // Whatever renders a device (this list, the device switcher, a create flow) + // reveals its section, so the opened item is never hidden behind a header. + useEffect(() => { + if (useDeviceSectionDisclosure.getState().expanded[activeSurfaceId] === false) { + setExpanded(activeSurfaceId, true); + } + }, [activeSurfaceId, setExpanded]); + + const containerRef = useRef(null); + useSwitchLayoutHold(containerRef, scrollRootRef); + + if (slots.length <= 1) { + return ; + } + return ( +
+ {slots.map(slot => )} +
+ ); +}; + +export default DeviceWorkspaceSections; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts new file mode 100644 index 0000000000..f291ccd7cb --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { catalogSessionStatus } from './catalogSessionStatus'; + +const summary = (extra: Record) => ({ + sessionId: 's', execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, ...extra, +}); + +describe('catalogSessionStatus', () => { + it('prioritizes interactions that block the host', () => { + expect(catalogSessionStatus(summary({ execution: 'running', pendingApprovals: 2 }))).toEqual({ kind: 'approval', pendingCount: 2 }); + expect(catalogSessionStatus(summary({ execution: 'running', pendingQuestions: 1 })).kind).toBe('input'); + }); + + it('maps execution and unread results', () => { + expect(catalogSessionStatus(summary({ execution: 'running' })).kind).toBe('running'); + expect(catalogSessionStatus(summary({ execution: 'queued' })).kind).toBe('queued'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'completed' })).kind).toBe('unread'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'interrupted' })).kind).toBe('stopped'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'error' })).kind).toBe('error'); + }); + + it('is idle without a summary', () => { + expect(catalogSessionStatus(undefined).kind).toBe('idle'); + expect(catalogSessionStatus(summary({})).kind).toBe('idle'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts new file mode 100644 index 0000000000..b19dc25d13 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts @@ -0,0 +1,40 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { sessionActivityStore } from '@/flow_chat/store/sessionActivityStore'; +import { crossDeviceActivity } from '@/flow_chat/services/crossDeviceSessions'; +import type { SessionNavStatus } from '@/flow_chat/utils/sessionNavStatus'; +import type { SessionActivitySummary } from '@/shared/types/session-history'; + +const IDLE: SessionNavStatus = { kind: 'idle', pendingCount: 0 }; + +/** + * Nav status of a session that has no live container on this window. Only the + * host's activity summary is known, so the projection is the summary's. + */ +export function catalogSessionStatus(summary: SessionActivitySummary | undefined): SessionNavStatus { + if (!summary) return IDLE; + if (summary.pendingApprovals > 0) return { kind: 'approval', pendingCount: summary.pendingApprovals }; + if (summary.pendingQuestions > 0) return { kind: 'input', pendingCount: summary.pendingQuestions }; + if (summary.execution === 'running') return { kind: 'running', pendingCount: 0 }; + if (summary.execution === 'queued') return { kind: 'queued', pendingCount: 0 }; + if (summary.execution === 'error' || summary.unreadCompletion === 'error') return { kind: 'error', pendingCount: 0 }; + if (summary.unreadCompletion === 'interrupted') return { kind: 'stopped', pendingCount: 0 }; + if (summary.unreadCompletion === 'completed') return { kind: 'unread', pendingCount: 0 }; + return IDLE; +} + +/** + * The host's catalog summary is refreshed on every lifecycle invalidation, so + * it wins; observed events only fill a session the catalog has no summary for. + */ +export function useCatalogSessionStatus( + surfaceId: DeviceSurfaceId, + sessionId: string, + pageSummary: SessionActivitySummary | undefined, +): SessionNavStatus { + const subscribe = useCallback((notify: () => void) => sessionActivityStore.subscribe((changedSurface, changedSession) => { + if (changedSurface === surfaceId && changedSession === sessionId) notify(); + }), [sessionId, surfaceId]); + const read = useCallback(() => crossDeviceActivity(surfaceId, sessionId, pageSummary), [pageSummary, sessionId, surfaceId]); + return catalogSessionStatus(useSyncExternalStore(subscribe, read, read)); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts new file mode 100644 index 0000000000..4900b46327 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + prewarm: vi.fn(), + sessions: new Map(), + adopt: vi.fn(async (_workspaceId: string, sessions: Array<{ sessionId: string }>) => { + for (const session of sessions) mocks.sessions.set(session.sessionId, session); + }), + intent: vi.fn(), + hold: vi.fn(), + clearIntent: vi.fn(), + promote: vi.fn(async () => undefined), + ensureMetadata: vi.fn(async () => true), + clearUnread: vi.fn(), + openMainSession: vi.fn(async () => undefined), + createChatDraft: vi.fn(async () => 'new-session'), + openLatestAssistant: vi.fn(async () => undefined), + workspaces: new Map(), + archiveRow: vi.fn(async () => undefined), + duplicateRow: vi.fn(async () => undefined), + renameRow: vi.fn(async () => undefined), + worktreeRow: vi.fn(async () => undefined), + isRunning: vi.fn(() => false), + capabilities: null as null | { wholeSessionCopyV1?: boolean }, + canReveal: vi.fn(() => false), + reveal: vi.fn(async () => undefined), + exportMarkdown: vi.fn(async () => ({ status: 'saved' })), +})); +vi.mock('../sessions/sessionRowActions', () => ({ + archiveSessionRow: mocks.archiveRow, + duplicateSessionRow: mocks.duplicateRow, + renameSessionRow: mocks.renameRow, + openWorktreeWorkspaceRow: mocks.worktreeRow, +})); +vi.mock('@/flow_chat/services/sessionNavStatusService', () => ({ sessionNavStatusService: { isRunning: mocks.isRunning } })); +vi.mock('@/infrastructure/peer-device/PeerConnectionManager', () => ({ + peerConnectionManager: { get: () => (mocks.capabilities ? { getState: () => ({ capabilities: mocks.capabilities }) } : undefined) }, +})); +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: { canRevealStorageDirectory: mocks.canReveal, revealStorageDirectory: mocks.reveal }, +})); +vi.mock('@/flow_chat/services/sessionMarkdownExport', () => ({ exportSessionToMarkdown: mocks.exportMarkdown })); + +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ + flowChatStore: { + ensurePersistedSessionMetadata: mocks.ensureMetadata, + clearSessionUnreadCompletion: mocks.clearUnread, + adoptHostSessionMetadata: mocks.adopt, + promoteSeededSessions: mocks.promote, + getState: () => ({ sessions: mocks.sessions }), + }, +})); +vi.mock('@/flow_chat/services/sessionOpenIntent', () => ({ + dispatchHistorySessionOpenIntent: mocks.intent, + clearHistorySessionOpenTransition: mocks.clearIntent, +})); +vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: { getState: () => ({ holdSessionScene: mocks.hold }) } })); +vi.mock('@/infrastructure/i18n', () => ({ i18nService: { t: (key: string) => key } })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn() }) })); +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ flowChatManager: { createChatDraft: mocks.createChatDraft } })); +vi.mock('@/flow_chat/services/sessionActivation', () => ({ openMainSession: mocks.openMainSession })); +vi.mock('@/flow_chat/services/RelaySessionHistory', () => ({ prewarmRelaySessionHistory: mocks.prewarm })); +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: { getState: () => ({ openedWorkspaces: mocks.workspaces }), setActiveWorkspace: vi.fn() }, +})); +vi.mock('@/app/utils/projectSessionWorkspace', () => ({ + findReusableEmptySessionId: () => null, + flowChatSessionConfigForWorkspace: (workspace: { id: string }) => ({ workspaceId: workspace.id }), +})); +vi.mock('../workspaces/openLatestAssistantSession', () => ({ openLatestAssistantSession: mocks.openLatestAssistant })); +vi.mock('@/shared/types', () => ({ WorkspaceKind: { Assistant: 'assistant', Normal: 'normal', Remote: 'remote' } })); + +import { activateSurface, resetDeviceSurfaceForTest, surfaceIdForDevice } from '@/infrastructure/peer-device/deviceSurface'; +import type { PeerDeviceContextValue } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { activateDeviceSlot, createDeviceSession, openDeviceAssistant, openDeviceSession, runDeviceSessionAction } from './openOnDevice'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +const peerSlot: SidebarDeviceSlot = { + surfaceId: 'peer-a', deviceId: 'peer-a', name: 'Studio Mac', isLocal: false, + rendered: false, hostKind: 'desktop', controllable: true, +}; +const localSlot: SidebarDeviceSlot = { ...peerSlot, surfaceId: 'local', deviceId: 'me', name: 'Laptop', isLocal: true }; + +function peerContext(outcome: 'activated' | 'superseded' = 'activated') { + return { + switchToDevice: vi.fn(async (deviceId: string) => { + if (outcome === 'activated') activateSurface(surfaceIdForDevice(deviceId)); + return outcome; + }), + switchToLocal: vi.fn(async () => { + if (outcome === 'activated') activateSurface(surfaceIdForDevice(null)); + return outcome; + }), + } as unknown as PeerDeviceContextValue & { + switchToDevice: ReturnType; + switchToLocal: ReturnType; + }; +} + +describe('openOnDevice', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetDeviceSurfaceForTest(); + mocks.workspaces.clear(); + mocks.sessions.clear(); + mocks.workspaces.set('ws-1', { id: 'ws-1', workspaceKind: 'normal', rootPath: '/p' }); + mocks.capabilities = null; + }); + + it('does not switch when the device is already rendered', async () => { + const peer = peerContext(); + expect(await activateDeviceSlot(peer, localSlot)).toBe('activated'); + expect(peer.switchToLocal).not.toHaveBeenCalled(); + expect(peer.switchToDevice).not.toHaveBeenCalled(); + }); + + it('shows the open overlay at once, promotes seeds after the switch, and opens a seeded session without a read', async () => { + mocks.sessions.set('s-1', { sessionId: 's-1' }); + const peer = peerContext(); + await openDeviceSession(peer, peerSlot, 'ws-1', { sessionId: 's-1' }, 'Title'); + expect(mocks.hold).toHaveBeenCalledWith({ surfaceId: 'peer-a', workspaceKey: '["workspace","ws-1"]', sessionId: 's-1' }); + expect(mocks.hold.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); + expect(mocks.intent.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); + expect(peer.switchToDevice).toHaveBeenCalledWith('peer-a', 'Studio Mac'); + // The session's stream starts before the switch, not after it. + expect(mocks.prewarm).toHaveBeenCalledWith('peer-a', 's-1'); + expect(mocks.prewarm.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); + expect(mocks.promote).toHaveBeenCalledOnce(); + expect(mocks.adopt).not.toHaveBeenCalled(); + expect(mocks.ensureMetadata).not.toHaveBeenCalled(); + expect(mocks.openMainSession).toHaveBeenCalledWith('s-1', expect.objectContaining({ workspaceId: 'ws-1' })); + }); + + it('adopts the row record when the session was not seeded yet', async () => { + await openDeviceSession(peerContext(), peerSlot, 'ws-1', { sessionId: 's-2', createdAt: 1 } as never); + expect(mocks.adopt).toHaveBeenCalledWith('ws-1', [{ sessionId: 's-2', createdAt: 1 }]); + expect(mocks.openMainSession).toHaveBeenCalledWith('s-2', expect.anything()); + }); + + it('fails loudly and clears the overlay when the host no longer has the session', async () => { + mocks.adopt.mockImplementationOnce(async () => undefined); + await expect(openDeviceSession(peerContext(), peerSlot, 'ws-1', { sessionId: 'gone', createdAt: 1 } as never)) + .rejects.toThrow('common:nav.devices.sessionUnavailable'); + expect(mocks.clearIntent).toHaveBeenCalledWith('gone'); + expect(mocks.openMainSession).not.toHaveBeenCalled(); + }); + + it('returns to this machine through the local switch', async () => { + activateSurface(surfaceIdForDevice('peer-a')); + const peer = peerContext(); + mocks.sessions.set('s-1', { sessionId: 's-1' }); + await openDeviceSession(peer, localSlot, 'ws-1', { sessionId: 's-1' } as never); + expect(peer.switchToLocal).toHaveBeenCalledOnce(); + expect(mocks.openMainSession).toHaveBeenCalledOnce(); + }); + + it('opens nothing when a newer switch superseded this one', async () => { + const peer = peerContext('superseded'); + await openDeviceSession(peer, peerSlot, 'ws-1', { sessionId: 's-1' } as never); + expect(mocks.clearIntent).toHaveBeenCalledWith('s-1'); + await openDeviceAssistant(peer, peerSlot, 'ws-1'); + await createDeviceSession(peer, peerSlot, 'ws-1'); + expect(mocks.adopt).not.toHaveBeenCalled(); + expect(mocks.openLatestAssistant).not.toHaveBeenCalled(); + expect(mocks.createChatDraft).not.toHaveBeenCalled(); + }); + + it('creates a session with the live workspace of the rendered device', async () => { + const peer = peerContext(); + await createDeviceSession(peer, peerSlot, 'ws-1'); + expect(mocks.createChatDraft).toHaveBeenCalledWith({ workspaceId: 'ws-1' }, undefined); + expect(mocks.openMainSession).toHaveBeenCalledWith('new-session', expect.objectContaining({ workspaceId: 'ws-1' })); + }); + + it('fails loudly when the workspace is gone on the device', async () => { + const peer = peerContext(); + await expect(createDeviceSession(peer, peerSlot, 'missing')).rejects.toThrow('missing'); + }); + + describe('session row actions', () => { + const t = (key: string) => key; + const seeded = { sessionId: 's-1', workspaceId: 'ws-1', config: {} }; + + it('renders the device first, then runs the live handler on it', async () => { + mocks.sessions.set('s-1', seeded); + const peer = peerContext(); + for (const action of [ + { kind: 'archive' as const }, + { kind: 'rename' as const, title: 'Renamed' }, + { kind: 'open-worktree' as const, worktreePath: '/wt' }, + ]) await runDeviceSessionAction(peer, peerSlot, 's-1', action, t); + await runDeviceSessionAction(peer, peerSlot, 's-1', { kind: 'export', scope: 'full', title: 'Chat' }, t); + expect(peer.switchToDevice).toHaveBeenCalledWith('peer-a', 'Studio Mac'); + const switchedAt = peer.switchToDevice.mock.invocationCallOrder[0]; + expect(mocks.promote.mock.invocationCallOrder[0]).toBeGreaterThan(switchedAt); + expect(mocks.archiveRow).toHaveBeenCalledWith('s-1', t); + expect(mocks.archiveRow.mock.invocationCallOrder[0]).toBeGreaterThan(mocks.promote.mock.invocationCallOrder[0]); + expect(mocks.renameRow).toHaveBeenCalledWith('s-1', 'Renamed', t); + expect(mocks.worktreeRow).toHaveBeenCalledWith('/wt', t); + expect(mocks.exportMarkdown).toHaveBeenCalledWith({ sessionId: 's-1', title: 'Chat', workspaceId: 'ws-1' }, 'full'); + }); + + it('runs nothing when a newer switch superseded the action', async () => { + mocks.sessions.set('s-1', seeded); + await runDeviceSessionAction(peerContext('superseded'), peerSlot, 's-1', { kind: 'archive' }, t); + expect(mocks.archiveRow).not.toHaveBeenCalled(); + }); + + it('fails loudly when the rendered device no longer has the session', async () => { + await expect(runDeviceSessionAction(peerContext(), peerSlot, 'gone', { kind: 'archive' }, t)) + .rejects.toThrow('common:nav.devices.sessionUnavailable'); + expect(mocks.archiveRow).not.toHaveBeenCalled(); + }); + + it('re-checks copy gates on the rendered device instead of faking success', async () => { + mocks.sessions.set('s-1', seeded); + mocks.capabilities = { wholeSessionCopyV1: false }; + await expect(runDeviceSessionAction(peerContext(), peerSlot, 's-1', { kind: 'duplicate' }, t)) + .rejects.toThrow('nav.sessions.duplicateHostUnsupported'); + mocks.capabilities = { wholeSessionCopyV1: true }; + mocks.isRunning.mockReturnValueOnce(true); + await expect(runDeviceSessionAction(peerContext(), peerSlot, 's-1', { kind: 'duplicate' }, t)) + .rejects.toThrow('nav.sessions.duplicateRunning'); + expect(mocks.duplicateRow).not.toHaveBeenCalled(); + await runDeviceSessionAction(peerContext(), peerSlot, 's-1', { kind: 'duplicate' }, t); + expect(mocks.duplicateRow).toHaveBeenCalledWith('s-1', t); + }); + + it('opens a storage directory only where the rendered surface can', async () => { + mocks.sessions.set('s-1', seeded); + await expect(runDeviceSessionAction(peerContext(), peerSlot, 's-1', { kind: 'reveal-storage' }, t)) + .rejects.toThrow('nav.sessions.storageDirectoryDesktopOnly'); + expect(mocks.reveal).not.toHaveBeenCalled(); + activateSurface(surfaceIdForDevice('peer-a')); + mocks.canReveal.mockReturnValueOnce(true); + await runDeviceSessionAction(peerContext(), localSlot, 's-1', { kind: 'reveal-storage' }, t); + expect(mocks.reveal).toHaveBeenCalledWith('ws-1', 's-1'); + }); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts new file mode 100644 index 0000000000..aada2505b8 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts @@ -0,0 +1,275 @@ +/** + * Open catalog entries of a device the window is not rendering. + * + * The catalog is read-only. Acting on an entry first renders its device through + * the Peer Device Mode surface switch, then runs the exact local handler on the + * now-rendered surface, so ownership, capability gates, permission prompts and + * surface fences are the ones remote control already uses. + */ + +import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +import { i18nService } from '@/infrastructure/i18n'; +import { clearHistorySessionOpenTransition, dispatchHistorySessionOpenIntent } from '@/flow_chat/services/sessionOpenIntent'; +import type { SessionMetadata } from '@/shared/types/session-history'; +import { createLogger } from '@/shared/utils/logger'; +import type { PeerDeviceContextValue } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; +import { prewarmRelaySessionHistory } from '@/flow_chat/services/RelaySessionHistory'; +import { findReusableEmptySessionId, flowChatSessionConfigForWorkspace } from '@/app/utils/projectSessionWorkspace'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { openLatestAssistantSession } from '../workspaces/openLatestAssistantSession'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { projectResourceSceneId } from '@/app/scenes/project-resources/projectResourceTarget'; +import { + clearWorkspaceAction, + requestWorkspaceAction, + type PendingWorkspaceActionKind, +} from '../workspaces/pendingWorkspaceAction'; +import { sessionSceneWorkspaceKey } from '@/app/services/sessionSceneTarget'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; +import { canCopySessionsOnSurface } from '@/infrastructure/peer-device/peerCapabilityResolution'; +import { sessionNavStatusService } from '@/flow_chat/services/sessionNavStatusService'; +import { requireSessionOwningWorkspaceId } from '@/flow_chat/utils/sessionOrdering'; +import { exportSessionToMarkdown } from '@/flow_chat/services/sessionMarkdownExport'; +import type { TranscriptExportScope } from '@/flow_chat/utils/dialogTranscriptExport'; +import { + archiveSessionRow, + duplicateSessionRow, + openWorktreeWorkspaceRow, + renameSessionRow, + type SessionRowTranslate, +} from '../sessions/sessionRowActions'; + +const log = createLogger('OpenOnDevice'); + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +export type DeviceActivationResult = 'activated' | 'superseded'; + +/** + * Render `slot`'s device. Returns `superseded` when a newer switch won. + * + * The device's sessions are already in its container (seeded from the device + * catalog), so nothing is read here beyond the switch itself. Seeds are + * promoted through the normal metadata path before anything opens them. + */ +export async function activateDeviceSlot( + peer: PeerDeviceContextValue | null, + slot: Pick, +): Promise { + if (getActiveSurfaceId() === slot.surfaceId) return 'activated'; + if (!peer) throw new Error('Peer Device Mode is unavailable in this window'); + if (!slot.isLocal && !slot.deviceId) throw new Error('Device id is unavailable'); + const startedAt = now(); + const outcome = slot.isLocal + ? await peer.switchToLocal('sidebar') + : await peer.switchToDevice(slot.deviceId!, slot.name); + log.info('Device surface rendered for open', { surfaceId: slot.surfaceId, outcome, switchMs: Math.round(now() - startedAt) }); + if (outcome === 'activated') { + const promoteStartedAt = now(); + try { + await flowChatStore.promoteSeededSessions(); + } catch (error) { + if (isSurfaceChangedError(error)) throw error; + log.warn('Failed to promote seeded sessions', { surfaceId: slot.surfaceId, error }); + } + log.info('Seeded sessions promoted', { surfaceId: slot.surfaceId, promoteMs: Math.round(now() - promoteStartedAt) }); + } + return outcome; +} + +/** The live record of a workspace on the rendered surface. */ +function liveWorkspace(workspaceId: string): WorkspaceInfo { + const workspace = workspaceManager.getState().openedWorkspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace is unavailable on this device: ${workspaceId}`); + return workspace; +} + +const activateWorkspace = (workspaceId: string) => workspaceManager.setActiveWorkspace(workspaceId); + +/** A cross-device open shows the main-area session overlay for this long at most. */ +const CROSS_DEVICE_OPEN_OVERLAY_MS = 20_000; + +export async function openDeviceSession( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + session: Pick & Partial, + title?: string, +): Promise { + const sessionId = session.sessionId; + const startedAt = now(); + // From the click on, the main area shows this session's tab and its loading + // presentation; the tab is held through the switch until the open settles. + if (getActiveSurfaceId() !== slot.surfaceId) { + // The session is what the user waits for: its host starts reading it now, + // while the device switch runs, instead of after it. + if (!slot.isLocal) prewarmRelaySessionHistory(slot.surfaceId, sessionId); + useSceneStore.getState().holdSessionScene({ + surfaceId: slot.surfaceId, workspaceKey: sessionSceneWorkspaceKey(workspaceId), sessionId, + }); + dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); + } + let settled = false; + try { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + const scope = getActiveSurfaceScope(); + if (!flowChatStore.getState().sessions.has(sessionId) && session.createdAt !== undefined) { + // Not seeded yet (opened before the catalog read finished): the row's own + // record is the host's, so no second read is needed. + await flowChatStore.adoptHostSessionMetadata(workspaceId, [session as SessionMetadata]); + scope.assertCurrent('adopt device catalog session'); + } + if (!flowChatStore.getState().sessions.has(sessionId)) { + throw new Error(i18nService.t('common:nav.devices.sessionUnavailable')); + } + settled = true; + } finally { + // A failed or superseded switch must not leave the overlay up. + if (!settled) clearHistorySessionOpenTransition(sessionId); + } + const scope = getActiveSurfaceScope(); + dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); + flowChatStore.clearSessionUnreadCompletion(sessionId); + await openMainSession(sessionId, { workspaceId, activateWorkspace, isCurrent: scope.isCurrent }); + log.info('Cross-device session opened', { surfaceId: slot.surfaceId, sessionId, totalMs: Math.round(now() - startedAt) }); +} + +export async function openDeviceAssistant( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + onDefaultUnavailable?: () => void, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + await openLatestAssistantSession(liveWorkspace(workspaceId), activateWorkspace, onDefaultUnavailable); +} + +/** Render the device, then let its live row run the action (menu, history). */ +export async function openDeviceWorkspaceAction( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + kind: PendingWorkspaceActionKind, +): Promise { + const action = { surfaceId: slot.surfaceId, workspaceId, kind }; + requestWorkspaceAction(action); + let activated = false; + try { + activated = await activateDeviceSlot(peer, slot) === 'activated'; + } finally { + if (!activated) clearWorkspaceAction(action); + } +} + +export async function openDeviceProjectResources( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + liveWorkspace(workspaceId); + useSceneStore.getState().openScene(projectResourceSceneId({ surfaceId: getActiveSurfaceId(), workspaceId })); +} + +export async function createDeviceSession( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + const scope = getActiveSurfaceScope(); + const workspace = liveWorkspace(workspaceId); + const mode = workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined; + // Same reuse rule as the local "new session" action. + const sessionId = findReusableEmptySessionId(workspace, mode) + ?? await flowChatManager.createChatDraft(flowChatSessionConfigForWorkspace(workspace), mode); + scope.assertCurrent('open new device session'); + await openMainSession(sessionId, { workspaceId, activateWorkspace, isCurrent: scope.isCurrent }); +} + +/** A session row menu action started on a device the window is not rendering. */ +export type DeviceSessionAction = + | { kind: 'rename'; title: string } + | { kind: 'duplicate' } + | { kind: 'export'; scope: TranscriptExportScope; title: string } + | { kind: 'open-worktree'; worktreePath: string } + | { kind: 'reveal-storage' } + | { kind: 'archive' }; + +/** + * Render `slot`'s device and return the session as the rendered surface knows + * it, or null when a newer switch won. A session the host no longer lists + * fails loudly instead of acting on a stale catalog row. + */ +export async function activateDeviceForSession( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + sessionId: string, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return null; + const session = flowChatStore.getState().sessions.get(sessionId); + if (!session) throw new Error(i18nService.t('common:nav.devices.sessionUnavailable')); + return session; +} + +/** Whole-session copy is negotiated per host; this machine always has it. */ +function deviceCanCopySessions(slot: SidebarDeviceSlot): boolean { + if (slot.isLocal) return true; + const capabilities = slot.deviceId ? peerConnectionManager.get(slot.deviceId)?.getState().capabilities ?? null : null; + return canCopySessionsOnSurface(true, capabilities); +} + +/** + * Render the session's device, then run the live row's handler for `action` + * on the now-rendered surface. Gates the live menu evaluates from rendered + * state (host capability, running turn, dispatch ownership) are re-checked + * here and fail loudly; nothing runs against another device. + */ +export async function runDeviceSessionAction( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + sessionId: string, + action: DeviceSessionAction, + t: SessionRowTranslate, +): Promise { + const session = await activateDeviceForSession(peer, slot, sessionId); + if (!session) return; + const dispatched = isNonLocalDispatchTarget(session.config.dispatchTarget); + switch (action.kind) { + case 'rename': + await renameSessionRow(sessionId, action.title, t); + return; + case 'duplicate': + if (dispatched) throw new Error(t('nav.sessions.duplicateDispatchUnsupported')); + if (!deviceCanCopySessions(slot)) throw new Error(t('nav.sessions.duplicateHostUnsupported')); + if (sessionNavStatusService.isRunning(sessionId)) throw new Error(t('nav.sessions.duplicateRunning')); + await duplicateSessionRow(sessionId, t); + return; + case 'export': + await exportSessionToMarkdown( + { sessionId, title: action.title, workspaceId: requireSessionOwningWorkspaceId(session) }, + action.scope, + ); + return; + case 'open-worktree': + await openWorktreeWorkspaceRow(action.worktreePath, t); + return; + case 'reveal-storage': + if (!sessionAPI.canRevealStorageDirectory() || dispatched) { + throw new Error(t('nav.sessions.storageDirectoryDesktopOnly')); + } + await sessionAPI.revealStorageDirectory(requireSessionOwningWorkspaceId(session), sessionId); + return; + case 'archive': + await archiveSessionRow(sessionId, t); + return; + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts new file mode 100644 index 0000000000..6d0577c729 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { devicePlatform } from './useSidebarDevices'; + +describe('devicePlatform', () => { + it('names the platform of reported OS strings and user agents', () => { + expect(devicePlatform('macOS 15.1')).toBe('mac'); + expect(devicePlatform('MacIntel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('mac'); + expect(devicePlatform('Windows 11')).toBe('windows'); + expect(devicePlatform('Ubuntu 24.04')).toBe('linux'); + expect(devicePlatform(null)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts new file mode 100644 index 0000000000..83d2b863a1 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts @@ -0,0 +1,118 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { useDeviceDirectory, isDeviceControllable, resolveDeviceNameFrom } from '@/infrastructure/account/deviceDirectory'; +import { getActiveSurfaceId, LOCAL_SURFACE_ID, onSurfaceActivated, surfaceIdForDevice, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { reportedHostKind, type DeviceOverviewHostKind } from '../../deviceInterconnectionOverview'; + +/** One device in the sidebar session list. */ +export interface SidebarDeviceSlot { + surfaceId: DeviceSurfaceId; + /** Account device id; null only while the local id is not known yet. */ + deviceId: string | null; + name: string; + isLocal: boolean; + /** The window renders this device; its slot uses the live workspace list. */ + rendered: boolean; + hostKind: DeviceOverviewHostKind | null; + /** Reported operating system, when known. */ + os: string | null; + /** Relay-confirmed mutual control; this machine is never gated by its own flag. */ + controllable: boolean; +} + +export type DevicePlatform = 'mac' | 'windows' | 'linux'; + +/** Platform of a reported OS string ("macOS 15", "Windows 11", user agent, ...). */ +export function devicePlatform(os: string | null | undefined): DevicePlatform | null { + const value = os?.toLowerCase() ?? ''; + if (/mac|darwin/.test(value)) return 'mac'; + if (/win/.test(value)) return 'windows'; + if (/linux|ubuntu|debian|fedora|arch|centos/.test(value)) return 'linux'; + return null; +} + +/** Devices that report a controller kind have no workspaces to list. */ +const CONTROLLER_KINDS = new Set(['mobile', 'watch']); + +/** + * The sidebar's device order: this machine first, then every online host of + * the account by name. The order never depends on which device is rendered, + * so opening something on another device does not move any section. + * + * Returns a single slot when there is nothing else to show, so a signed-out or + * single-device window renders exactly the plain workspace list. + */ +export function useSidebarDevices(localLabel: string): SidebarDeviceSlot[] { + const directory = useDeviceDirectory(); + const peer = usePeerDeviceModeOptional(); + const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const renderedPeer = peer?.peerMode.active ? peer.peerMode : null; + const attachments = peer?.attachments; + + return useMemo(() => { + const localId = directory.localId; + const local = localId ? directory.devices.find(device => device.device_id === localId) : undefined; + const slots: SidebarDeviceSlot[] = [{ + surfaceId: LOCAL_SURFACE_ID, + deviceId: localId, + name: local ? resolveDeviceNameFrom(directory.devices, local.device_id) : localLabel, + isLocal: true, + rendered: activeSurfaceId === LOCAL_SURFACE_ID, + hostKind: reportedHostKind(local?.device_kind) ?? 'desktop', + os: local?.device_os ?? (typeof navigator === 'undefined' ? null : `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`), + controllable: true, + }]; + const peers: SidebarDeviceSlot[] = directory.devices + .filter(device => device.device_id !== localId && device.online + && !CONTROLLER_KINDS.has(device.device_kind?.trim().toLowerCase() ?? '')) + .map(device => { + const surfaceId = surfaceIdForDevice(device.device_id); + const linkKind = attachments?.find(attachment => attachment.deviceId === device.device_id)?.capabilities?.hostKind; + return { + surfaceId, + deviceId: device.device_id, + name: resolveDeviceNameFrom(directory.devices, device.device_id), + isLocal: false, + rendered: activeSurfaceId === surfaceId, + hostKind: linkKind ?? reportedHostKind(device.device_kind), + os: device.device_os ?? null, + controllable: isDeviceControllable(device), + }; + }); + // Presence can lag behind a live control link; the rendered peer stays listed. + if (renderedPeer && !peers.some(slot => slot.deviceId === renderedPeer.deviceId)) { + peers.push({ + surfaceId: surfaceIdForDevice(renderedPeer.deviceId), + deviceId: renderedPeer.deviceId, + name: resolveDeviceNameFrom(directory.devices, renderedPeer.deviceId, renderedPeer.deviceName), + isLocal: false, + rendered: true, + hostKind: attachments?.find(attachment => attachment.deviceId === renderedPeer.deviceId)?.capabilities?.hostKind ?? null, + os: directory.devices.find(device => device.device_id === renderedPeer.deviceId)?.device_os ?? null, + controllable: true, + }); + } + peers.sort((a, b) => a.name.localeCompare(b.name) || a.surfaceId.localeCompare(b.surfaceId)); + return [...slots, ...peers]; + }, [activeSurfaceId, attachments, directory.devices, directory.localId, localLabel, renderedPeer]); +} + +interface DeviceSectionDisclosureState { + /** Explicit choices only; absent means open. */ + expanded: Record; + setExpanded: (surfaceId: DeviceSurfaceId, expanded: boolean) => void; +} + +export const useDeviceSectionDisclosure = create()(persist( + set => ({ + expanded: {}, + setExpanded: (surfaceId, expanded) => set(state => ({ expanded: { ...state.expanded, [surfaceId]: expanded } })), + }), + { + name: 'openbitfun.sidebar-device-sections.v1', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ expanded: state.expanded }), + }, +)); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts new file mode 100644 index 0000000000..31dc9e3e0b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts @@ -0,0 +1,18 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { getActiveSurfaceId, onSurfaceActivated, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; + +/** A surface's sessions; re-renders on seeding and on rendered-surface updates. */ +export function useSurfaceSessions(surfaceId: DeviceSurfaceId): ReadonlyMap { + const subscribe = useCallback((notify: () => void) => { + const unsubscribeSurface = flowChatStore.subscribeSurfaceSessions(changed => { if (changed === surfaceId) notify(); }); + // Rendered-surface updates (streaming included) matter only while this + // device is the rendered one; selectors keep other devices quiet. + const unsubscribeActive = flowChatStore.subscribe(() => { if (getActiveSurfaceId() === surfaceId) notify(); }); + const unsubscribeActivation = onSurfaceActivated(notify); + return () => { unsubscribeSurface(); unsubscribeActive(); unsubscribeActivation(); }; + }, [surfaceId]); + const read = useCallback(() => flowChatStore.getSurfaceSessions(surfaceId), [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/ArchivedSessionMenuItems.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/ArchivedSessionMenuItems.tsx index 3d0c7a342a..29dc2f23b0 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/ArchivedSessionMenuItems.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/ArchivedSessionMenuItems.tsx @@ -20,9 +20,14 @@ interface Props { workspaceName: string; onClose: () => void; onChanged: () => void; + /** + * Render the session's device before acting (device catalog rows). Resolves + * false when a newer switch won; the action then does nothing. + */ + activate?: () => Promise; } -export default function ArchivedSessionMenuItems({ session, title, workspaceName, onClose, onChanged }: Props) { +export default function ArchivedSessionMenuItems({ session, title, workspaceName, onClose, onChanged, activate }: Props) { const { t } = useI18n('common'); const pendingRef = useRef(false); const [pending, setPending] = useState(false); @@ -31,7 +36,7 @@ export default function ArchivedSessionMenuItems({ session, title, workspaceName if (pendingRef.current) return; pendingRef.current = true; setPending(true); - const scope = getActiveSurfaceScope(); + let scope = getActiveSurfaceScope(); const labels = { name: title, workspace: workspaceName }; const sourceView = session.externalHistory?.mode === 'follow'; try { @@ -43,10 +48,18 @@ export default function ArchivedSessionMenuItems({ session, title, workspaceName ); if (!confirmed) return; scope.assertCurrent('delete archived session'); + if (activate) { + if (!await activate()) return; + scope = getActiveSurfaceScope(); + } await flowChatManager.deleteChatSession(session.sessionId); scope.assertCurrent('finish deleting archived session'); notificationService.success(t(sourceView ? 'nav.sessions.removedSourceSucceeded' : 'nav.sessions.deleteArchivedSucceeded', labels)); } else { + if (activate) { + if (!await activate()) return; + scope = getActiveSurfaceScope(); + } const workspaceId = requireSessionOwningWorkspaceId(session); await sessionAPI.unarchiveSession(session.sessionId, workspaceId); scope.assertCurrent('finish restoring archived session'); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx index 69968eefc7..f6faa6b250 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx @@ -3,6 +3,7 @@ import { Icon } from '@openbitfun/ui'; import { CircleAlert, CircleCheck, CirclePause, CircleStop, CloudOff, Hand, Loader2, MessageCircleQuestion } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { useSessionNavStatus } from '@/flow_chat/hooks/useSessionNavStatus'; +import type { SessionNavStatus } from '@/flow_chat/utils/sessionNavStatus'; import './SessionStatusIndicator.scss'; const presentation = { @@ -17,20 +18,15 @@ const presentation = { syncing: { glyph: CloudOff, tone: 'secondary', label: 'nav.sessions.status.syncing' }, } as const; -export const SessionStatusIndicator = memo(function SessionStatusIndicator({ - sessionId, +/** The status cell for a status the caller already derived. */ +export const SessionStatusMark = memo(function SessionStatusMark({ + status, idleFallback, }: { - sessionId: string; - /** - * Rendered in the same trailing cell while the session has nothing to report, - * so a second row mark can sit on the row's right edge without claiming a - * second cell. The cell still yields to the row menu on hover or focus. - */ + status: SessionNavStatus; idleFallback?: ReactNode; }) { const { t } = useI18n('common'); - const status = useSessionNavStatus(sessionId); const appearance = status.kind === 'idle' ? null : presentation[status.kind]; const label = appearance ? status.kind === 'approval' && status.pendingCount > 1 @@ -66,3 +62,19 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ ) : null; }); + +export const SessionStatusIndicator = memo(function SessionStatusIndicator({ + sessionId, + idleFallback, +}: { + sessionId: string; + /** + * Rendered in the same trailing cell while the session has nothing to report, + * so a second row mark can sit on the row's right edge without claiming a + * second cell. The cell still yields to the row menu on hover or focus. + */ + idleFallback?: ReactNode; +}) { + const status = useSessionNavStatus(sessionId); + return ; +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index fffbefb580..dedda9d6f9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -74,6 +74,7 @@ } &__inline-loading { + animation: openbitfun-nav-inline-loading-reveal 160ms ease-out 200ms both; display: flex; align-items: center; gap: 5px; @@ -284,8 +285,9 @@ } // Preserve title geometry while the project name yields to row actions. - .openbitfun-nav-panel__inline-item:hover .openbitfun-nav-panel__inline-item-workspace-name, - .openbitfun-nav-panel__inline-item:focus-within .openbitfun-nav-panel__inline-item-workspace-name, + // Rows without actions keep their name visible. + .openbitfun-nav-panel__inline-item:has(.openbitfun-nav-panel__inline-item-actions):hover .openbitfun-nav-panel__inline-item-workspace-name, + .openbitfun-nav-panel__inline-item:has(.openbitfun-nav-panel__inline-item-actions):focus-within .openbitfun-nav-panel__inline-item-workspace-name, .openbitfun-nav-panel__inline-item.is-menu-open .openbitfun-nav-panel__inline-item-workspace-name { visibility: hidden; } @@ -940,3 +942,10 @@ @include nav-font.nav-panel-text-icon; } } + +// A wait that ends quickly shows nothing: the indicator fades in only once +// the wait has lasted long enough to be noticed. +@keyframes openbitfun-nav-inline-loading-reveal { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 02c2f82b0e..0b90f8b189 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -23,7 +23,6 @@ import { useSceneStore } from '../../../../stores/sceneStore'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { sessionAPI, type SessionCatalogQuery, type SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; import { createLogger } from '@/shared/utils/logger'; -import { isSamePath } from '@/shared/utils/pathUtils'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { @@ -50,6 +49,7 @@ import { matchesWorkspaceSourceAgent, sessionSourceAgentLabel, useWorkspaceSessionViewStore, + workspaceSessionViewKey, } from '../../workspaceSessionView'; import { stateMachineManager } from '@/flow_chat/state-machine'; import { sessionNavStatusService } from '@/flow_chat/services/sessionNavStatusService'; @@ -57,8 +57,9 @@ import { i18nService } from '@/infrastructure/i18n'; import { isDefaultSessionTitle, resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; +import { getChildSessionBadge } from './sessionRowBadges'; import { useSessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; -import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { getActiveSurfaceId, getActiveSurfaceScope, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; import { EXTERNAL_SESSION_NAMES, isExternalSessionEcosystem } from '@/shared/types/external-session'; import { getWorkspaceSessionHierarchy } from './sessionNavHierarchy'; import { useWorkspaceSessionCatalog } from './useWorkspaceSessionCatalog'; @@ -67,6 +68,10 @@ import ArchivedSessionMenuItems from './ArchivedSessionMenuItems'; import { captureWorkspaceSessionCatalogQuery, countPersistedWorkspaceCatalogSessions, resolveWorkspaceSessionCatalogSource, workspaceSessionCatalogQuery, workspaceSessionCatalogSources, workspaceSessionCatalogTotal } from './workspaceSessionCatalogQuery'; import { useWorkspaceSessionDiscovery } from './useWorkspaceSessionDiscovery'; import { useSessionNavExpansion } from './useSessionNavExpansion'; +import { + DEFAULT_SESSION_VIEW_KEY, knownWorkspaceSessionTotal, presentedSessionTotal, rememberWorkspaceSessionTotal, +} from '@/flow_chat/services/workspaceSessionTotals'; + import { isSessionNavRowActive } from './sessionNavSelection'; import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { @@ -86,7 +91,6 @@ import { exportSessionToMarkdown } from '@/flow_chat/services/sessionMarkdownExp import type { TranscriptExportScope } from '@/flow_chat/utils/dialogTranscriptExport'; import { AssistantAvatar } from '@/app/components/AssistantAvatar'; import { notificationService } from '@/shared/notification-system'; -import { isOutcomeUnknownError } from '@/infrastructure/api/errors/TauriCommandError'; import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { isNonLocalDispatchTarget, @@ -110,22 +114,44 @@ import { import { getSessionDisplayLimit, getSessionExpandToggleState, - SESSIONS_LEVEL_0, - SESSIONS_LEVEL_1, + nextSessionDisplayCount, + SESSIONS_INITIAL_COUNT, + SESSIONS_LEVEL_2_PAGE, + SESSIONS_PREFETCH_COUNT, } from './sessionNavExpand'; import { useSessionRowRemovalTransition } from './sessionRowShift'; import { SessionStatusIndicator } from './SessionStatusIndicator'; import { createSessionsNavSelector } from './sessionsNavSelector'; +import { archiveSessionRow, duplicateSessionRow, openWorktreeWorkspaceRow, renameSessionRow } from './sessionRowActions'; import './SessionsSection.scss'; +/** A limited flat list reads each workspace's newest rows in steps of this many. */ +const AGGREGATE_READ_BUCKET = 20; const log = createLogger('SessionsSection'); +/** How long the pointer rests on a session row before its history is read. */ +const SESSION_HOVER_PREWARM_MS = 120; +let sessionHoverPrewarmTimer: ReturnType | null = null; + +function prewarmSessionOnHover(sessionId: string): void { + if (sessionHoverPrewarmTimer !== null) clearTimeout(sessionHoverPrewarmTimer); + sessionHoverPrewarmTimer = setTimeout(() => { + sessionHoverPrewarmTimer = null; + if (flowChatStore.getState().activeSessionId !== sessionId) flowChatManager.prewarmHistoricalSession(sessionId); + }, SESSION_HOVER_PREWARM_MS); +} + +function cancelSessionHoverPrewarm(): void { + if (sessionHoverPrewarmTimer !== null) clearTimeout(sessionHoverPrewarmTimer); + sessionHoverPrewarmTimer = null; +} + const reportedDiscoveryNotificationKeys = new Set(); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending'; /** Page size for the fully-expanded (level 2) session list. */ -const SESSIONS_LEVEL_2_PAGE = 200; + const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); @@ -142,22 +168,6 @@ function DefaultSessionTitlePreview({ sessionId, createdAt }: Pick { - const normalizedKind = - kind === 'review' || kind === 'deep_review' || kind === 'subagent' - ? kind - : 'btw'; - const fallback = normalizedKind === 'deep_review' - ? 'Strict' - : normalizedKind === 'review' - ? 'Review' - : normalizedKind === 'subagent' - ? 'Agent' - : 'btw'; - return i18nService.t(`flow-chat:childSession.kinds.${normalizedKind}.short`, { - defaultValue: fallback, - }); -}; const getReviewActivityBadge = (kind: 'review' | 'deep_review'): string => i18nService.t( @@ -196,6 +206,8 @@ interface SessionsSectionProps { workspaceScopes?: WorkspaceSessionScope[]; /** Navigation geometry for nested workspace rows versus the flat aggregate list. */ layout?: 'nested' | 'flat'; + /** Flat lists of a multi-device sidebar show three rows plus "show more". */ + limitFlatRows?: boolean; /** Close a containing history surface after explicit session navigation. */ onSessionOpened?: () => void; /** Assistant history owns persistence; ordinary project rows do not expose this action. */ @@ -222,6 +234,7 @@ const SessionsSection: React.FC = ({ useWorkspaceViewPreferences = false, workspaceScopes, layout = 'nested', + limitFlatRows = false, onSessionOpened, defaultSessionAction, }) => { @@ -242,9 +255,10 @@ const SessionsSection: React.FC = ({ ? storedSessionFilters : DEFAULT_WORKSPACE_SESSION_FILTERS; const includeArchivedSessions = sessionShow === 'archived' || !sessionFilters.hideArchived; - const showAllWithoutLimit = layout === 'flat' && Boolean(workspaceScopes?.length); + const isAggregateList = layout === 'flat' && Boolean(workspaceScopes?.length); + const showAllWithoutLimit = isAggregateList && !limitFlatRows; const sessionListClassName = `openbitfun-nav-panel__inline-list${layout === 'flat' ? ' is-flat-workspace-view' : ''}`; - const { setActiveWorkspace, openWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext(); + const { setActiveWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext(); const activeTabId = useSceneStore(s => s.activeTabId); const activeBtwSessionTab = useAgentCanvasStore(state => selectActiveBtwSessionTab(state as any)); const activeBtwSessionData = activeBtwSessionTab?.content.data as @@ -262,9 +276,12 @@ const SessionsSection: React.FC = ({ const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); - const { expandLevel, level2DisplayCount, setExpandLevel, setLevel2DisplayCount } = useSessionNavExpansion( + const isVisibleRef = useRef(isVisible); + isVisibleRef.current = isVisible; + const { displayCount, setDisplayCount } = useSessionNavExpansion( `${surfaceScopedKey(getActiveSurfaceId(), workspaceId)}:${sourceAgent}`, - SESSIONS_LEVEL_2_PAGE, + // Collapsing a workspace and opening it again starts at the first rows. + { visible: isVisible, shouldHandOff: () => isVisibleRef.current }, ); const surface = getActiveSurfaceScope(); const initialMetadataKey = surface.key(workspaceId, surface.epoch); @@ -282,10 +299,19 @@ const SessionsSection: React.FC = ({ isLoading: boolean; failedScopeCount: number; }>(() => ({ - isLoading: showAllWithoutLimit && isVisible, + isLoading: isAggregateList && isVisible, failedScopeCount: 0, })); const [aggregateReloadRequestId, setAggregateReloadRequestId] = useState(0); + /** Host-reported top-level total of a limited flat list. */ + const [aggregateTotal, setAggregateTotal] = useState(0); + // Rows a limited flat list keeps loaded: shown rows plus one step, rounded + // up so a few "show more" clicks share one read. It only grows, so + // collapsing never re-reads. + const aggregateTargetRef = useRef(0); + aggregateTargetRef.current = Math.max(aggregateTargetRef.current, + Math.ceil((displayCount + SESSIONS_PREFETCH_COUNT) / AGGREGATE_READ_BUCKET) * AGGREGATE_READ_BUCKET); + const aggregateTarget = aggregateTargetRef.current; const [catalogRefreshVersion, setCatalogRefreshVersion] = useState(0); const onDiscoveryCompleted = useCallback(() => { setCatalogRefreshVersion(value => value + 1); @@ -403,7 +429,8 @@ const SessionsSection: React.FC = ({ onPage: observeDiscovery, query: catalogQuery, captureQuery: captureCatalogQuery, - targetCount: expandLevel === 2 ? level2DisplayCount : expandLevel === 1 ? SESSIONS_LEVEL_1 : SESSIONS_LEVEL_0, + // One step ahead of what is shown, so "show more" reveals loaded rows. + targetCount: displayCount + SESSIONS_PREFETCH_COUNT, firstPage: metadataPageState.loadedScope === initialMetadataKey ? metadataPageState.firstPage : undefined, getKnownCount: getKnownCatalogCount, }); @@ -501,19 +528,26 @@ const SessionsSection: React.FC = ({ setAggregateLoadState({ isLoading: true, failedScopeCount: 0 }); const loadAllScopes = async () => { - const results = await Promise.allSettled(workspaceScopes.map(async scope => { + const totals: number[] = []; + const results = await Promise.allSettled(workspaceScopes.map(async (scope, index) => { let cursor: string | undefined; do { + // A limited list needs each workspace's newest rows only: the merged + // top rows come from them, and the host totals give the exact rest. const page = await flowChatStore.loadSessionMetadataPage( - scope.workspaceId, SESSIONS_LEVEL_2_PAGE, cursor, + scope.workspaceId, limitFlatRows ? aggregateTarget : SESSIONS_LEVEL_2_PAGE, cursor, 'sessions_nav_all_grouping', { fresh: aggregateReloadRequestId > 0 }, ); if (cancelled) return; observeDiscovery(scope.workspaceId, page); - cursor = page.hasMore ? page.nextCursor : undefined; + totals[index] = page.totalTopLevelCount; + cursor = page.hasMore && !limitFlatRows ? page.nextCursor : undefined; } while (cursor); })); + if (!cancelled && limitFlatRows) { + setAggregateTotal(totals.reduce((sum, total) => sum + (total ?? 0), 0)); + } if (cancelled) return; @@ -542,7 +576,9 @@ const SessionsSection: React.FC = ({ return () => { cancelled = true; }; }, [ aggregateReloadRequestId, + aggregateTarget, isVisible, + limitFlatRows, observeDiscovery, workspaceScopes, workspaceScopesKey, @@ -607,7 +643,7 @@ const SessionsSection: React.FC = ({ } initialMetadataLoadKeyRef.current = initialMetadataKey; - const page = await loadMetadataPage(SESSIONS_LEVEL_0, undefined, source); + const page = await loadMetadataPage(SESSIONS_INITIAL_COUNT + SESSIONS_PREFETCH_COUNT, undefined, source); if (!page && initialMetadataLoadKeyRef.current === initialMetadataKey) { initialMetadataLoadKeyRef.current = null; } @@ -686,19 +722,19 @@ const SessionsSection: React.FC = ({ const handler = () => { metadataLoadRequestIdRef.current += 1; setCatalogRefreshVersion(value => value + 1); - setExpandLevel(0); + setDisplayCount(SESSIONS_INITIAL_COUNT); setMetadataPageState({ loadedScope: null, isLoading: false, loadError: false, }); if (isVisible && workspaceId) { - void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_post_archive'); + void loadMetadataPage(SESSIONS_INITIAL_COUNT + SESSIONS_PREFETCH_COUNT, undefined, 'sessions_nav_post_archive'); } }; window.addEventListener('openbitfun:session-archived', handler); return () => window.removeEventListener('openbitfun:session-archived', handler); - }, [isVisible, workspaceId, loadMetadataPage, setExpandLevel]); + }, [isVisible, workspaceId, loadMetadataPage, setDisplayCount]); const closeSessionMenu = useCallback(() => { setOpenMenuSessionId(null); @@ -815,15 +851,32 @@ const SessionsSection: React.FC = ({ const sessionDisplayLimit = useMemo(() => { return getSessionDisplayLimit({ loadedTopLevelCount: topLevelSessions.length, - expandLevel, - level2DisplayCount, + displayCount, showAllWithoutLimit, }); - }, [topLevelSessions.length, expandLevel, level2DisplayCount, showAllWithoutLimit]); + }, [topLevelSessions.length, displayCount, showAllWithoutLimit]); // Only an acknowledged workspace query provides a scoped total. Include // newly-created local rows while their metadata publication is in flight. - const totalTopLevelSessionCount = workspaceSessionCatalogTotal(topLevelSessions.length, catalog); + // Host totals count every top-level session, so they only bound the default + // view; a filtered or source-scoped list counts its own rows. + const totalViewKey = workspaceSessionViewKey(sourceAgent, sessionShow, sessionFilters); + const liveTopLevelTotal = Math.max( + workspaceSessionCatalogTotal(topLevelSessions.length, catalog), + limitFlatRows && isAggregateList && totalViewKey === DEFAULT_SESSION_VIEW_KEY ? aggregateTotal : 0, + ); + // While the catalog reader restarts (device switch, refresh, new epoch) only + // loaded rows are countable; keep the last settled total so the "show more" + // row and its count never vanish and come back. + const catalogSettled = catalog.complete && !catalog.loading; + const rememberedTotal = workspaceId && !workspaceScopes?.length + ? knownWorkspaceSessionTotal(surface.surfaceId, workspaceId, totalViewKey) : undefined; + const totalTopLevelSessionCount = presentedSessionTotal(catalogSettled, liveTopLevelTotal, rememberedTotal); + useEffect(() => { + if (catalogSettled && workspaceId && !workspaceScopes?.length) { + rememberWorkspaceSessionTotal(surface.surfaceId, workspaceId, totalViewKey, liveTopLevelTotal); + } + }, [catalogSettled, liveTopLevelTotal, surface.surfaceId, totalViewKey, workspaceId, workspaceScopes?.length]); const knownCatalogCount = getKnownCatalogCount(); useEffect(() => { // Page hydration updates the baseline inside the reader. A later local @@ -832,8 +885,9 @@ const SessionsSection: React.FC = ({ && catalog.knownCount !== knownCatalogCount) setCatalogRefreshVersion(value => value + 1); }, [catalog.complete, catalog.knownCount, catalog.loading, catalog.paged, isVisible, knownCatalogCount]); const isSessionCountComplete = !workspaceId || Boolean(workspaceScopes?.length) - || (catalog.complete && !discovery.pending && !discovery.failed); - const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); + || (catalog.complete && !discovery.pending && !discovery.failed) + || (!catalogSettled && rememberedTotal !== undefined); + const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, displayCount); // The visible label stays short ("Show more") and the remaining count rides in // a trailing `+N` chip; screen readers get the full sentence via aria-label. // Keyed off `action` so the label, the chevron direction and the CSS hook on @@ -844,21 +898,13 @@ const SessionsSection: React.FC = ({ remainingCount: number | null; } => { if (expandToggleState.action === 'show-more') { - const count = expandToggleState.collapsedRemainingCount; + const count = expandToggleState.remainingCount; return { label: t('nav.sessions.showMoreLabel'), ariaLabel: isSessionCountComplete ? t('nav.sessions.showMore', { count }) : t('nav.sessions.showMoreLabel'), remainingCount: isSessionCountComplete ? count : null, }; } - if (expandToggleState.action === 'show-all') { - const count = expandToggleState.expandedRemainingCount; - return { - label: t('nav.sessions.showAllLabel'), - ariaLabel: isSessionCountComplete ? t('nav.sessions.showAll', { count }) : t('nav.sessions.showAllLabel'), - remainingCount: isSessionCountComplete ? count : null, - }; - } return { label: t('nav.sessions.showLess'), ariaLabel: t('nav.sessions.showLess'), @@ -866,8 +912,7 @@ const SessionsSection: React.FC = ({ }; }, [ expandToggleState.action, - expandToggleState.collapsedRemainingCount, - expandToggleState.expandedRemainingCount, + expandToggleState.remainingCount, isSessionCountComplete, t, ]); @@ -1133,13 +1178,7 @@ const SessionsSection: React.FC = ({ const handleArchive = useCallback( async (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); - try { - await flowChatManager.archiveChatSession(sessionId); - window.dispatchEvent(new CustomEvent('openbitfun:session-archived')); - notificationService.success(t('nav.sessions.archivedAll', { count: 1 }), { duration: 3000 }); - } catch (err) { - log.error('Failed to archive session', err); - } + await archiveSessionRow(sessionId, t); }, [t] ); @@ -1157,27 +1196,7 @@ const SessionsSection: React.FC = ({ setDuplicatingSessionId(sessionId); try { - const copied = await flowChatManager.duplicateChatSession(sessionId); - if (copied.metadataProjected) { - notificationService.success( - t('nav.sessions.duplicateSuccess', { name: copied.sessionName }), - { duration: 3000 }, - ); - } else { - notificationService.warning( - t('nav.sessions.duplicateCreatedRefreshFailed', { name: copied.sessionName }), - { duration: 5000 }, - ); - } - } catch (error) { - if (isSurfaceChangedError(error)) return; - log.error('Failed to copy session', { sessionId, error }); - notificationService.error( - error instanceof Error && error.message - ? error.message - : t('nav.sessions.duplicateFailed'), - { duration: 4000 }, - ); + await duplicateSessionRow(sessionId, t); } finally { setDuplicatingSessionId(null); } @@ -1185,34 +1204,14 @@ const SessionsSection: React.FC = ({ [closeSessionMenu, duplicatingSessionId, t], ); - /** - * A worktree directory is registered for execution but not opened, so the - * session row is the only place it can be reached from. Opening it as a - * workspace is an explicit user action; if the worktree happens to be open - * already, activating it is the whole effect. - */ + /** Open (or activate) the worktree directory a session runs in as a workspace. */ const handleOpenWorktreeWorkspace = useCallback( async (e: React.MouseEvent, worktreePath: string) => { e.stopPropagation(); closeSessionMenu(); - const opened = openedWorkspacesList.find(workspace => - isSamePath(workspace.rootPath ?? '', worktreePath) - ); - try { - if (opened) { - await setActiveWorkspace(opened.id); - return; - } - await openWorkspace(worktreePath); - } catch (err) { - log.error('Failed to open the worktree directory as a workspace', { - worktreePath, - error: err, - }); - notificationService.error(t('nav.sessions.openWorktreeWorkspaceFailed'), { duration: 3000 }); - } + await openWorktreeWorkspaceRow(worktreePath, t); }, - [closeSessionMenu, openWorkspace, openedWorkspacesList, setActiveWorkspace, t] + [closeSessionMenu, t] ); const handleStartEdit = useCallback( @@ -1228,22 +1227,7 @@ const SessionsSection: React.FC = ({ if (!editingSessionId) return; const trimmed = editingTitle.trim(); if (trimmed) { - try { - await flowChatManager.renameChatSessionTitle(editingSessionId, trimmed); - } catch (err) { - log.error('Failed to update session title', { sessionId: editingSessionId, error: err }); - if (isOutcomeUnknownError(err)) { - notificationService.warning(t('nav.sessions.renameOutcomeUnknown'), { duration: 6000 }); - try { - await flowChatManager.reloadSessionTitle(editingSessionId); - } catch (refreshError) { - log.error('Failed to reload the session title after an unknown rename outcome', { - sessionId: editingSessionId, - error: refreshError, - }); - } - } - } + await renameSessionRow(editingSessionId, trimmed, t); } setEditingSessionId(null); setEditingTitle(''); @@ -1268,16 +1252,8 @@ const SessionsSection: React.FC = ({ ); const handleExpandToggle = useCallback(() => { - if (expandLevel === 0) { - setExpandLevel(1); - return; - } - if (expandLevel === 1 && totalTopLevelSessionCount > SESSIONS_LEVEL_1) { - setExpandLevel(2); - return; - } - setExpandLevel(0); - }, [expandLevel, setExpandLevel, totalTopLevelSessionCount]); + setDisplayCount(nextSessionDisplayCount(displayCount, totalTopLevelSessionCount)); + }, [displayCount, setDisplayCount, totalTopLevelSessionCount]); const sourceFilterErrorStatus = discovery.failed ? ( @@ -1299,7 +1275,7 @@ const SessionsSection: React.FC = ({ )}
) : null; - const aggregateLoadStatus = showAllWithoutLimit + const aggregateLoadStatus = isAggregateList ? aggregateLoadState.isLoading ? (
= ({ return (
- {sourceFilterErrorStatus ?? backgroundLoadStatus} + {/* A refresh of a populated list must not push its rows down. */} + {sourceFilterErrorStatus} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const isArchived = session.persistedStatus === 'archived'; @@ -1609,6 +1586,8 @@ const SessionsSection: React.FC = ({ data-session-level={String(level)} data-session-active={isRowActive ? 'true' : 'false'} onPointerDown={event => handleSessionOpenPointerDown(event, session)} + onPointerEnter={() => prewarmSessionOnHover(session.sessionId)} + onPointerLeave={cancelSessionHoverPrewarm} onClick={() => handleSwitch(session.sessionId)} onContextMenu={event => handleContextMenu(event, session.sessionId)} > @@ -1976,44 +1955,18 @@ const SessionsSection: React.FC = ({ ); })} - {aggregateLoadStatus} - - {!showAllWithoutLimit && expandLevel === 2 && totalTopLevelSessionCount > sessionDisplayLimit && ( - - )} + {/* A populated list refreshes silently: a loading row would push "show more" down and back. Failures still show. */} + {aggregateLoadState.isLoading ? null : aggregateLoadStatus} {!showAllWithoutLimit && expandToggleState.shouldRender && ( - - - + ; } const render = (scope: string, count: number) => act(() => { root.render(); }); - const click = (action: string) => act(() => { container.querySelector(`[data-action="${action}"]`)!.click(); }); + const click = () => act(() => { container.querySelector('[data-action="toggle"]')!.click(); }); const count = () => Number(container.querySelector('output')?.textContent); beforeEach(() => { @@ -38,40 +35,55 @@ describe('source-filter session expansion', () => { container.remove(); }); - it('starts each agent filter with five rows, including the first render after an expanded view', () => { - render('local:project:all', 350); - expect(count()).toBe(5); - click('all'); - expect(count()).toBe(200); - render('local:project:codex', 300); - expect(renders.filter(render => render.scope === 'local:project:codex').every(render => render.rows === 5)).toBe(true); - expect(count()).toBe(5); - click('more'); - expect(count()).toBe(10); - render('local:project:zcode', 50); - expect(count()).toBe(5); - render('local:project:codex', 300); - expect(count()).toBe(5); - render('local:project:all', 350); - expect(count()).toBe(5); + it('starts every view compact, including the first render after an expanded view', () => { + render('local:project:all', 30); + expect(count()).toBe(3); + click(); + click(); + expect(count()).toBe(13); + render('local:project:codex', 30); + expect(renders.filter(entry => entry.scope === 'local:project:codex').every(entry => entry.rows === 3)).toBe(true); + }); + + it('adds five per click and collapses once everything is shown', () => { + render('local:project:all', 12); + click(); + expect(count()).toBe(8); + click(); + expect(count()).toBe(12); + click(); + expect(count()).toBe(3); + }); + + it('hands the count to the list that replaces it after a device switch', () => { + render('peer:project:all', 30); + click(); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:all', 30); + expect(count()).toBe(8); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:other', 30); + expect(count()).toBe(3); + }); + + it('starts over at three rows when a collapsed workspace opens again', () => { + render('local:project:all', 30); + click(); + expect(count()).toBe(8); + act(() => { root.render(); }); + act(() => { root.render(); }); + expect(count()).toBe(3); }); - it('keeps newly loaded metadata folded and expands only through the list controls', () => { - render('local:project:codex', 3); + it('does not hand a collapsed list count to the next mount', () => { + act(() => { root.render(); }); + click(); + act(() => { root.render(); }); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:all', 30); expect(count()).toBe(3); - render('local:project:codex', 600); - expect(count()).toBe(5); - click('more'); - expect(count()).toBe(10); - click('all'); - expect(count()).toBe(200); - click('next'); - expect(count()).toBe(400); - click('less'); - expect(count()).toBe(5); - click('all'); - expect(count()).toBe(200); - render('peer:project:codex', 600); - expect(count()).toBe(5); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts index 47c1002be9..63af6a370f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts @@ -1,39 +1,65 @@ -import { useCallback, useEffect, useState, type SetStateAction } from 'react'; -import type { SessionExpandLevel } from './sessionNavExpand'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { SESSIONS_INITIAL_COUNT } from './sessionNavExpand'; + +/** + * A device switch remounts a workspace's rows (background projection to live + * list and back). The count a list showed when it unmounted is handed to the + * next list mounting with the same scope shortly after, so "show more" stays + * expanded through the switch. A scope change inside one list still starts + * compact. + */ +const HANDOFF_MS = 30_000; +const handoffs = new Map(); + +function takeHandoff(scope: string) { + const entry = handoffs.get(scope); + handoffs.delete(scope); + return entry && Date.now() - entry.at <= HANDOFF_MS ? entry : undefined; +} /** Data discovery never expands the list. Each workspace/source view starts * compact, even when another view already has hundreds of metadata rows. */ -export function useSessionNavExpansion(scope: string, pageSize: number) { - const [state, setState] = useState({ scope, level: 0 as SessionExpandLevel, displayCount: pageSize }); +export function useSessionNavExpansion( + scope: string, + options: { + /** A collapsed list starts over at the first rows when reopened. */ + visible?: boolean; + /** Checked on unmount: only a remount (device switch) keeps the count. */ + shouldHandOff?: () => boolean; + } = {}, +) { + const { visible = true } = options; + const shouldHandOff = useRef(options.shouldHandOff); + shouldHandOff.current = options.shouldHandOff; + const [state, setState] = useState(() => ({ + scope, displayCount: takeHandoff(scope)?.displayCount ?? SESSIONS_INITIAL_COUNT, + })); + const latest = useRef(state); + latest.current = state; + useEffect(() => () => { + const { scope: last, displayCount } = latest.current; + if (displayCount > SESSIONS_INITIAL_COUNT && (shouldHandOff.current?.() ?? true)) { + const now = Date.now(); + // Unclaimed hand-offs (a list that never remounted) lapse here. + for (const [key, entry] of handoffs) if (now - entry.at > HANDOFF_MS) handoffs.delete(key); + handoffs.set(last, { displayCount, at: now }); + } + }, []); useEffect(() => { - setState(previous => previous.scope === scope ? previous : { scope, level: 0, displayCount: pageSize }); - }, [pageSize, scope]); - - const setExpandLevel = useCallback((level: SessionExpandLevel) => { - setState(previous => ({ - scope, - level, - displayCount: level === 2 && previous.scope === scope ? previous.displayCount : pageSize, - })); - }, [pageSize, scope]); + if (!visible) setState(previous => previous.displayCount === SESSIONS_INITIAL_COUNT ? previous : { scope, displayCount: SESSIONS_INITIAL_COUNT }); + }, [scope, visible]); + useEffect(() => { + setState(previous => previous.scope === scope ? previous : { scope, displayCount: SESSIONS_INITIAL_COUNT }); + }, [scope]); - const setLevel2DisplayCount = useCallback((next: SetStateAction) => { - setState(previous => { - const current = previous.scope === scope ? previous.displayCount : pageSize; - return { - scope, - level: previous.scope === scope ? previous.level : 0, - displayCount: typeof next === 'function' ? next(current) : next, - }; - }); - }, [pageSize, scope]); + const setDisplayCount = useCallback((displayCount: number) => { + setState({ scope, displayCount: Math.max(SESSIONS_INITIAL_COUNT, displayCount) }); + }, [scope]); return { // Limit the very first render after a filter/device switch, before effects // run, so the previous view cannot briefly mount its entire expanded list. - expandLevel: state.scope === scope ? state.level : 0 as SessionExpandLevel, - level2DisplayCount: state.scope === scope ? state.displayCount : pageSize, - setExpandLevel, - setLevel2DisplayCount, + displayCount: state.scope === scope ? state.displayCount : SESSIONS_INITIAL_COUNT, + setDisplayCount, }; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 5fc0fe4ec2..39f3bdb733 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -16,6 +16,7 @@ import { DialogHeading, DialogTitle, OverflowText, + Spinner, StatusPill, } from '@openbitfun/ui'; import React, { Suspense, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; @@ -26,6 +27,7 @@ import { InputDialog } from '@/app/components/InputDialog'; import { WorkspaceIcon } from '@/app/components/WorkspaceIcon/WorkspaceIcon'; import { AssistantAvatar } from '@/app/components/AssistantAvatar'; import { openLatestAssistantSession } from './openLatestAssistantSession'; +import { takeWorkspaceAction, usePendingWorkspaceAction } from './pendingWorkspaceAction'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; @@ -83,6 +85,22 @@ const AssistantSessionHistory = lazyWithRecovery(() => import('./AssistantSessio const WorkspaceSessionBatchModal = lazyWithRecovery(() => import('./WorkspaceSessionBatchModal')); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); +/** Fast creates finish inside this window and never flash a spinner. */ +const NEW_SESSION_PENDING_VISIBLE_DELAY_MS = 140; + +function useDelayedPendingIndicator(pending: boolean): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (!pending) { + setVisible(false); + return; + } + const timer = window.setTimeout(() => setVisible(true), NEW_SESSION_PENDING_VISIBLE_DELAY_MS); + return () => window.clearTimeout(timer); + }, [pending]); + return pending && visible; +} + const MAX_WORKSPACE_NAME_CHARS = 80; function containsWorkspaceNameControlCharacter(value: string): boolean { @@ -153,6 +171,15 @@ const WorkspaceItem: React.FC = ({ const [localSessionsCollapsed, setSessionsCollapsed] = useState(false); const sessionsCollapsed = controlledSessionsCollapsed ?? localSessionsCollapsed; const [assistantHistoryOpen, setAssistantHistoryOpen] = useState(false); + // An action started on this workspace's row of another device, before the + // device was rendered, runs here once. + const pendingAction = usePendingWorkspaceAction(state => state.action); + useEffect(() => { + if (!pendingAction) return; + const kind = takeWorkspaceAction(getActiveSurfaceId(), workspace.id); + if (kind === 'menu') { setMenuContextPoint(null); setMenuOpen(true); } + if (kind === 'assistant-history') setAssistantHistoryOpen(true); + }, [pendingAction, workspace.id]); const assistantHistoryId = useId(); const openingAssistantRef = useRef(false); const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); @@ -716,8 +743,16 @@ const WorkspaceItem: React.FC = ({ } }, [t, workspace.rootPath]); + // One new session per gesture: a second click while the first is being + // created would open a second draft. The pending state is rendered on the + // button (busy + disabled) so a slow create, e.g. over a peer relay, is + // visibly in progress instead of silently swallowing clicks. + const [creatingSession, setCreatingSession] = useState(false); + const showCreatingSessionIndicator = useDelayedPendingIndicator(creatingSession); const handleCreateSession = useCallback(async (mode?: 'Standard' | 'Cowork' | 'Claw') => { setMenuOpen(false); + if (creatingSession) return; + setCreatingSession(true); const resolvedMode = mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined); try { const reusableId = findReusableEmptySessionId(workspace, resolvedMode); @@ -741,8 +776,11 @@ const WorkspaceItem: React.FC = ({ error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), { duration: 4000 } ); + } finally { + setCreatingSession(false); } }, [ + creatingSession, setActiveWorkspace, t, workspace, @@ -822,7 +860,7 @@ const WorkspaceItem: React.FC = ({ 'openbitfun-nav-panel__assistant-item', isSelected && 'is-active', isDragging && 'is-dragging', - menuOpen && 'is-menu-open', + (menuOpen || creatingSession) && 'is-menu-open', isSingle && 'is-single', ].filter(Boolean).join(' ')} data-openbitfun-product-component="workspace-item" @@ -918,13 +956,17 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-product-component="workspace-item" data-openbitfun-product-part="action" type="button" - className="openbitfun-nav-panel__assistant-item-menu-trigger" + className={`openbitfun-nav-panel__assistant-item-menu-trigger${showCreatingSessionIndicator ? ' is-pending' : ''}`} onClick={() => { void handleCreateSession(); }} + disabled={creatingSession} + aria-busy={creatingSession || undefined} aria-label={t('nav.workspaces.actions.newSession')} data-testid="nav-workspace-new-session-btn" data-workspace-id={workspace.id} > - + {showCreatingSessionIndicator + ? + : } @@ -1085,7 +1127,7 @@ const WorkspaceItem: React.FC = ({ 'openbitfun-nav-panel__workspace-item', isSelected && 'is-active', isDragging && 'is-dragging', - menuOpen && 'is-menu-open', + (menuOpen || creatingSession) && 'is-menu-open', sessionsCollapsed && 'is-sessions-collapsed', isSingle && 'is-single', ].filter(Boolean).join(' ')} @@ -1366,13 +1408,17 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-product-component="workspace-item" data-openbitfun-product-part="action" type="button" - className="openbitfun-nav-panel__workspace-item-menu-trigger" + className={`openbitfun-nav-panel__workspace-item-menu-trigger${showCreatingSessionIndicator ? ' is-pending' : ''}`} onClick={handleCreateProjectSession} + disabled={creatingSession} + aria-busy={creatingSession || undefined} aria-label={t('nav.sessions.newSession')} data-testid="nav-workspace-new-session-btn" data-workspace-id={workspace.id} > - + {showCreatingSessionIndicator + ? + : } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx index 6dcafe8a73..dccf49bbdc 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx @@ -37,7 +37,10 @@ vi.mock('@/app/stores/sceneStore', async () => { return { useSceneStore: create(() => ({ activeTabId: null as SceneTabId | null, openTabs: [] as SceneTab[] })) }; }); vi.mock('../../workspaceSessionView', () => ({ - useWorkspaceSessionViewStore: (selector: (value: typeof state) => unknown) => selector(state), + useWorkspaceSessionViewStore: Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { getState: () => state }, + ), })); vi.mock('./WorkspaceItem', () => ({ default: ({ workspace, isActive, isSelected, draggable, isDragging, onDragStart, onDragEnd, sessionsCollapsed, onSessionsCollapsedChange }: { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index b168e0b494..8ce7921d45 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -6,6 +6,7 @@ import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; import { getActiveSurfaceId, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; import { notificationService } from '@/shared/notification-system'; import WorkspaceItem from './WorkspaceItem'; +import { setWorkspaceSessionsCollapsed, useWorkspaceSessionsCollapsed } from './workspaceDisclosure'; import { remoteWorkspaceBadges } from './remoteWorkspaceBadge'; import SessionViewTransition from './SessionViewTransition'; import SessionsSection, { type WorkspaceSessionScope } from '../sessions/SessionsSection'; @@ -22,6 +23,8 @@ import './WorkspaceListSection.scss'; interface WorkspaceListSectionProps { variant: SessionNavigationScope; scrollRootRef?: React.RefObject; + /** Several devices share the sidebar: the flat list shows three rows per device. */ + limitFlatRows?: boolean; } type WorkspaceDragPosition = 'before' | 'after'; @@ -43,7 +46,24 @@ const WORKSPACE_DROP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; * never joins the workspace order (see `pinnedGroups`). */ -const WorkspaceListSection: React.FC = ({ variant, scrollRootRef }) => { +/** A workspace row whose project disclosure lives in the window-wide store. */ +const ProjectWorkspaceItem: React.FC & { + surfaceId: string; + isProject: boolean; +}> = ({ surfaceId, isProject, ...props }) => { + const collapsed = useWorkspaceSessionsCollapsed(surfaceId, props.workspace.id); + return ( + setWorkspaceSessionsCollapsed(surfaceId, props.workspace.id, value) + : undefined} + /> + ); +}; + +const WorkspaceListSection: React.FC = ({ variant, scrollRootRef, limitFlatRows = false }) => { const { t } = useI18n('common'); const { openedWorkspacesList, @@ -65,11 +85,6 @@ const WorkspaceListSection: React.FC = ({ variant, sc activeTab?.contentId ? state.resources[activeTab.contentId]?.scope : undefined )); const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); - const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); - const [projectDisclosure, setProjectDisclosure] = useState<{ - requestId: number; - values: Record; - }>({ requestId: collapseAllRequestId, values: {} }); // Refs for values that must be read inside event handlers without stale closures const draggedWorkspaceIdRef = useRef(null); @@ -348,9 +363,6 @@ const WorkspaceListSection: React.FC = ({ variant, sc */ const renderGroupRow = (group: WorkspaceBackedSessionGroup, pinned: boolean) => { const workspace = group.workspace; - const disclosureKey = JSON.stringify([surfaceId, workspace.id]); - const sessionsCollapsed = (projectDisclosure.requestId === collapseAllRequestId - ? projectDisclosure.values[disclosureKey] : undefined) ?? (collapseAllRequestId > 0); const isActive = isWorkspaceBackedSessionGroupActive(workspace, activeWorkspace); const isSelected = isWorkspaceBackedSessionGroupSelected(workspace, { activeTab, contentScope, surfaceId, activeWorkspaceId, @@ -382,7 +394,9 @@ const WorkspaceListSection: React.FC = ({ variant, sc onDragLeave={draggable ? handleDragLeave(workspace.id) : undefined} onDrop={draggable ? (event) => { void handleDrop(workspace.id)(event); } : undefined} > - = ({ variant, sc isDragging={draggedWorkspaceId === workspace.id} onDragStart={draggable ? handleDragStart(workspace.id) : undefined} onDragEnd={draggable ? resetDragState : undefined} - sessionsCollapsed={group.kind === 'project' ? sessionsCollapsed : undefined} - onSessionsCollapsedChange={group.kind === 'project' ? collapsed => { - setProjectDisclosure(current => ({ - requestId: collapseAllRequestId, - values: { - ...(current.requestId === collapseAllRequestId ? current.values : {}), - [disclosureKey]: collapsed, - }, - })); - } : undefined} /> {dropTarget?.workspaceId === workspace.id ? ( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts new file mode 100644 index 0000000000..83709df1e6 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { requestWorkspaceAction, takeWorkspaceAction } from './pendingWorkspaceAction'; + +describe('pendingWorkspaceAction', () => { + it('runs once, on the row of the same device and workspace only', () => { + requestWorkspaceAction({ surfaceId: 'peer', workspaceId: 'w', kind: 'menu' }); + expect(takeWorkspaceAction('local', 'w')).toBeNull(); + expect(takeWorkspaceAction('peer', 'other')).toBeNull(); + expect(takeWorkspaceAction('peer', 'w')).toBe('menu'); + expect(takeWorkspaceAction('peer', 'w')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts new file mode 100644 index 0000000000..c8b5074b43 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** + * An action the user started on another device's workspace row (its "more" + * menu, the assistant history). The device is rendered first; the live row + * for the same workspace then runs the action once, so the menu or panel is + * the real one with that device's capabilities. + */ +export type PendingWorkspaceActionKind = 'menu' | 'assistant-history'; + +interface PendingWorkspaceAction { + surfaceId: DeviceSurfaceId; + workspaceId: string; + kind: PendingWorkspaceActionKind; +} + +/** An action whose row never mounted (the workspace closed meanwhile) lapses. */ +const PENDING_WORKSPACE_ACTION_TTL_MS = 30_000; +let requestedAt = 0; + +export const usePendingWorkspaceAction = create<{ action: PendingWorkspaceAction | null }>(() => ({ action: null })); + +export function requestWorkspaceAction(action: PendingWorkspaceAction): void { + requestedAt = Date.now(); + usePendingWorkspaceAction.setState({ action }); +} + +/** Withdraw `action` if it is still the pending one. */ +export function clearWorkspaceAction(action: PendingWorkspaceAction): void { + const pending = usePendingWorkspaceAction.getState().action; + if (pending && pending.surfaceId === action.surfaceId && pending.workspaceId === action.workspaceId + && pending.kind === action.kind) usePendingWorkspaceAction.setState({ action: null }); +} + +/** Take the action if it belongs to this row; it runs at most once. */ +export function takeWorkspaceAction(surfaceId: DeviceSurfaceId, workspaceId: string): PendingWorkspaceActionKind | null { + const { action } = usePendingWorkspaceAction.getState(); + if (action && Date.now() - requestedAt > PENDING_WORKSPACE_ACTION_TTL_MS) { + usePendingWorkspaceAction.setState({ action: null }); + return null; + } + if (!action || action.surfaceId !== surfaceId || action.workspaceId !== workspaceId) return null; + usePendingWorkspaceAction.setState({ action: null }); + return action.kind; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts new file mode 100644 index 0000000000..d82c51ef07 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; + +/** + * Which project rows are collapsed, per device. Window-wide rather than + * component state: a device switch remounts its list, and the live and + * background projections of one device must agree on what is open. + * + * A "collapse all" request collapses every row until the user opens one again, + * matching the previous component-local behaviour. + */ +export const workspaceDisclosureKey = (surfaceId: DeviceSurfaceId, workspaceId: string) => JSON.stringify([surfaceId, workspaceId]); + +const useWorkspaceDisclosureStore = create<{ requestId: number; values: Record }>(() => ({ + requestId: 0, + values: {}, +})); + +export function useWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string): boolean { + const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); + const value = useWorkspaceDisclosureStore(state => ( + state.requestId === collapseAllRequestId ? state.values[workspaceDisclosureKey(surfaceId, workspaceId)] : undefined + )); + return value ?? collapseAllRequestId > 0; +} + +export function isWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string): boolean { + const collapseAllRequestId = useWorkspaceSessionViewStore.getState().collapseAllRequestId; + const state = useWorkspaceDisclosureStore.getState(); + const value = state.requestId === collapseAllRequestId ? state.values[workspaceDisclosureKey(surfaceId, workspaceId)] : undefined; + return value ?? collapseAllRequestId > 0; +} + +export function setWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string, collapsed: boolean): void { + const requestId = useWorkspaceSessionViewStore.getState().collapseAllRequestId; + useWorkspaceDisclosureStore.setState(state => ({ + requestId, + values: { ...(state.requestId === requestId ? state.values : {}), [workspaceDisclosureKey(surfaceId, workspaceId)]: collapsed }, + })); +} diff --git a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts index 8a6b34a188..3aaf6988f4 100644 --- a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts @@ -7,6 +7,23 @@ function source(relativePath: string): string { } describe('unified project session creation', () => { + it('shows a pending new-session button instead of silently swallowing clicks', () => { + const workspaceItem = source('./sections/workspaces/WorkspaceItem.tsx'); + expect(workspaceItem).not.toContain('creatingSessionRef'); + expect(workspaceItem).toContain('const [creatingSession, setCreatingSession] = useState(false);'); + const buttons = workspaceItem.split('data-testid="nav-workspace-new-session-btn"'); + expect(buttons).toHaveLength(3); + for (const before of buttons.slice(0, 2)) { + const button = before.slice(before.lastIndexOf(''))).toContain(''); + } + }); + it('keeps New Session in workspace actions and opens the conversation dock before voice', () => { const mainNav = source('./MainNav.tsx'); const workspaceBody = source('../../layout/WorkspaceBody.tsx'); @@ -98,9 +115,12 @@ describe('unified project session creation', () => { expect(source('../../navigation/sidebarContributions.ts')).toContain("'todos', 'surface.todos.open'"); expect(mainNav).not.toContain("new Set(['sessions'])"); expect(mainNav).toContain('label={t(\'nav.items.sessions\')}'); - const sessionLists = mainNav.match(/]*\/>/g) ?? []; - expect(sessionLists).toHaveLength(1); - expect(sessionLists[0]).toMatch(/\svariant\s*=\s*["']all["']/); + // One session region; it is grouped by device when other hosts are online. + expect(mainNav.match(/]*\/>/g) ?? []).toHaveLength(1); + const deviceSections = source('./sections/devices/DeviceWorkspaceSections.tsx'); + const sessionLists = deviceSections.match(/]*\/>/g) ?? []; + expect(sessionLists.length).toBeGreaterThan(0); + for (const list of sessionLists) expect(list).toMatch(/\svariant\s*=\s*["']all["']/); expect(workspaceList).toContain('variant: SessionNavigationScope'); expect(workspaceList).toContain("variant === 'all'"); expect(workspaceList).toContain('openedWorkspacesList'); @@ -239,8 +259,10 @@ describe('unified project session creation', () => { expect(sessionsSection).toContain('const archivedCatalog = useArchivedSessionMetadata({'); expect(archivedMetadata).toContain('flowChatStore.loadArchivedSessionMetadata('); expect(sessionsSection).toContain("layout === 'flat' ? ' is-flat-workspace-view'"); - expect(sessionsSection).toContain("const showAllWithoutLimit = layout === 'flat'"); - expect(sessionsSection).toContain('!showAllWithoutLimit && expandLevel === 2'); + expect(sessionsSection).toContain("const isAggregateList = layout === 'flat'"); + expect(sessionsSection).toContain('const showAllWithoutLimit = isAggregateList && !limitFlatRows'); + // One toggle adds rows step by step; there is no separate load-more row. + expect(sessionsSection).not.toContain('nav-session-list-load-more'); expect(sessionsSection).toContain('!showAllWithoutLimit && expandToggleState.shouldRender'); expect(sessionsSection).toContain('openbitfun-nav-panel__inline-item-workspace-name'); }); diff --git a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts index 46bfa0efae..366c219127 100644 --- a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts +++ b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { DEFAULT_SESSION_VIEW_KEY } from '@/flow_chat/services/workspaceSessionTotals'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { Session } from '@/flow_chat/types/flow-chat'; import { isWorktreeIsolatedSession } from '@/flow_chat/utils/sessionOrdering'; @@ -256,6 +257,20 @@ export function matchesWorkspaceSessionView( return true; } +/** + * Identity of a session view for remembered totals. The default view (all + * sources, all sessions, no filters) is the one a plain host total describes. + */ +export function workspaceSessionViewKey( + sourceAgent: WorkspaceSessionSourceAgent, + show: WorkspaceSessionShow, + filters: WorkspaceSessionFilters, +): string { + return sourceAgent === 'all' && show === 'all' && !hasWorkspaceSessionFilters(filters) + ? DEFAULT_SESSION_VIEW_KEY + : JSON.stringify([sourceAgent, show, filters]); +} + export function hasWorkspaceSessionFilters(filters: WorkspaceSessionFilters): boolean { return filters.hiddenStatuses.length > 0 || filters.hiddenEnvironments.length > 0 diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts index bde972348b..156e19674b 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts @@ -4,6 +4,6 @@ export const newProjectDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = parts: [ { id: 'root' }, { id: 'title' }, { id: 'content' }, { id: 'field' }, { id: 'pathSelector' }, - { id: 'error' }, { id: 'footer' }, { id: 'choices' }, + { id: 'error' }, { id: 'footer' }, { id: 'choices' }, { id: 'devices' }, ], }; diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss index d4eb78a90f..87e041a3c4 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss @@ -38,12 +38,128 @@ min-inline-size: 0; } + &__choices--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + &__devices { + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-2); + min-inline-size: 0; + max-block-size: 360px; + overflow-y: auto; + } + + // Devices read as one list of quiet rows: the icon in a soft disc, the + // name, its host tag, and a chevron that leans in on hover. + &__device-card { + min-block-size: 0; + border-radius: var(--openbitfun-radius-lg); + + &:hover { + outline-color: transparent; + } + + [data-openbitfun-part='leading'] { + flex-basis: 32px; + inline-size: 32px; + block-size: 32px; + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 8%, transparent); + color: var(--openbitfun-color-content-primary); + } + } + + &__device-trigger { + justify-content: flex-start; + padding: var(--openbitfun-space-3) var(--openbitfun-space-4) var(--openbitfun-space-3) var(--openbitfun-space-3); + gap: var(--openbitfun-space-3); + } + + &__device-content { + display: flex; + flex: 1 1 auto; + align-items: baseline; + gap: var(--openbitfun-space-2); + min-inline-size: 0; + } + + &__device-name { + min-inline-size: 0; + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-md-font-family); + font-size: var(--openbitfun-type-body-md-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-md-line-height); + } + + &__device-meta { + flex: 0 0 auto; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + &__device-chevron { + align-self: center; + flex: 0 0 auto; + margin-inline-start: auto; + color: var(--openbitfun-color-content-muted); + transition: + color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + &__device-card:hover:not([data-disabled='true']) &__device-chevron { + color: var(--openbitfun-color-content-secondary); + transform: translateX(2px); + } + + &__device-spinner { + animation: new-project-dialog-spin 1s linear infinite; + } + + // Each choice is a pane with its icon in a soft disc; hover brightens the + // pane and its disc together and lifts the card a hair, never an outline. &__choice-card { - min-block-size: 160px; + min-block-size: 148px; + border-radius: var(--openbitfun-radius-xl); + transition: + background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); &:hover { outline-color: transparent; } + + &:hover:not([data-disabled='true']) { + transform: translateY(-1px); + } + + &:active:not([data-disabled='true']) { + transform: none; + } + + [data-openbitfun-part='leading'] { + flex-basis: 40px; + inline-size: 40px; + block-size: 40px; + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 8%, transparent); + color: var(--openbitfun-color-content-primary); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + &:hover:not([data-disabled='true']) [data-openbitfun-part='leading'] { + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 12%, transparent); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + &:hover:not([data-disabled='true']) { + transform: none; + } + } } &__choice-trigger { @@ -51,7 +167,7 @@ align-items: flex-start; justify-content: flex-start; padding: var(--openbitfun-space-5); - gap: var(--openbitfun-space-3); + gap: var(--openbitfun-space-4); } &__choice-content { @@ -71,7 +187,7 @@ } &__choice-description { - color: var(--openbitfun-color-content-secondary); + color: var(--openbitfun-color-content-muted); font-family: var(--openbitfun-type-body-sm-font-family); font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-body-sm-font-weight); @@ -89,7 +205,7 @@ &__folder { min-width: 0; - min-block-size: 160px; + min-block-size: 112px; justify-content: center; gap: var(--openbitfun-space-3); } @@ -191,3 +307,9 @@ grid-template-columns: minmax(0, 1fr); } } + +@keyframes new-project-dialog-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx index 9f05b4a04a..81792952fc 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx @@ -80,13 +80,12 @@ describe('NewProjectDialog composition', () => { await act(async () => root.render()); }; - it('disables WSL before selection when the active host does not support it', async () => { + it('offers no WSL choice when the active host does not support it', async () => { wslSupport.supported = false; const onSelectRemoteHost = vi.fn(); await render({ onPickRemoteFolder: vi.fn(), onSelectRemoteHost }); - expect(sourceOption(1, 'wsl').disabled).toBe(true); + expect(sourceOption(1, 'wsl')).toBeNull(); expect(document.body.textContent).not.toContain('ssh.remote.wslUnsupported'); - act(() => sourceOption(1, 'wsl').click()); expect(sourceOption(1, 'system').getAttribute('aria-checked')).toBe('true'); expect(onSelectRemoteHost).not.toHaveBeenCalled(); }); @@ -376,7 +375,7 @@ describe('NewProjectDialog composition', () => { expect(button('actions.add').disabled).toBe(true); act(() => document.querySelector('[data-workspace-choice="fixture-ssh"] button[aria-pressed]')!.click()); expect(sourceOption(1, 'container').getAttribute('aria-checked')).toBe('true'); - expect(sourceOption(1, 'wsl').disabled).toBe(true); + expect(sourceOption(1, 'wsl')).toBeNull(); expect(button('actions.add').disabled).toBe(true); act(() => document.querySelector('[data-workspace-choice="fixture-remoteDocker"] button[aria-pressed]')!.click()); expect(button('actions.add').disabled).toBe(false); @@ -395,12 +394,10 @@ describe('NewProjectDialog composition', () => { await click('actions.add'); expect(sourceOption(0, 'other').disabled).toBe(true); expect(sourceOption(1, 'container').disabled).toBe(true); - expect(document.body.textContent).toContain('workspaceSource.foldersLockedHint'); act(() => document.querySelector('button[aria-label="newProject.removeFolder"]')!.click()); expect(sourceOption(0, 'other').disabled).toBe(false); expect(sourceOption(1, 'container').disabled).toBe(false); expect(sourceOption(1, 'container').getAttribute('aria-checked')).toBe('true'); - expect(document.body.textContent).not.toContain('workspaceSource.foldersLockedHint'); }); it('names the executing peer host without changing folder routing to the controller', async () => { @@ -518,4 +515,69 @@ describe('NewProjectDialog composition', () => { expect(createAssistant).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); }); + + describe('creating on another device', () => { + const devices = [ + { surfaceId: 'peer-mac', name: 'Studio Mac', hostKind: 'desktop' as const, rendered: false, controllable: true }, + { surfaceId: 'peer-old', name: 'Old Laptop', hostKind: 'desktop' as const, rendered: false, controllable: false }, + ]; + const deviceOption = (surfaceId: string) => document.querySelector( + `[data-testid="new-project-device-option"][data-device-surface="${surfaceId}"] button, button[data-testid="new-project-device-option"][data-device-surface="${surfaceId}"]`, + )!; + const open = async (extra: Record) => { + await act(async () => root.render()); + }; + + it('renders the chosen device before showing the same project form there', async () => { + const selectDevice = vi.fn(async (target: string) => { + activateSurface(surfaceIdForDevice(target === 'local' ? null : target)); + return true; + }); + await open({ onSelectDevice: selectDevice, deviceOptions: devices }); + expect(document.querySelectorAll('[data-openbitfun-component="action-card"] button')).toHaveLength(3); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + expect(document.querySelector('[data-testid="new-project-device-list"]')).not.toBeNull(); + expect(deviceOption('peer-old').disabled).toBe(true); + expect(selectDevice).not.toHaveBeenCalled(); + + await act(async () => { deviceOption('peer-mac').click(); }); + expect(selectDevice).toHaveBeenCalledWith('peer-mac'); + expect(nameInput()).toBe(document.activeElement); + expect(document.body.textContent).toContain('newWorkspace.createProjectOn'); + enterName('Remote app'); + await add('/Users/me/app'); + await click('newWorkspace.createProject'); + expect(confirm).toHaveBeenCalledWith({ name: 'Remote app', folders: [{ path: '/Users/me/app' }] }); + }); + + it('keeps the device list when the switch is superseded or fails', async () => { + const selectDevice = vi.fn().mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('Device offline')); + await open({ onSelectDevice: selectDevice, deviceOptions: devices }); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + await act(async () => { deviceOption('peer-mac').click(); }); + expect(nameInput()).toBeNull(); + await act(async () => { deviceOption('peer-mac').click(); }); + expect(document.querySelector('[role="alert"]')?.textContent).toContain('Device offline'); + expect(document.querySelector('[data-testid="new-project-device-list"]')).not.toBeNull(); + }); + + it('explains an empty or signed-out device list', async () => { + await open({ onSelectDevice: vi.fn(), deviceOptions: [] }); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + expect(document.body.textContent).toContain('newWorkspace.noOtherDevices'); + await open({ onSelectDevice: vi.fn(), deviceOptions: undefined }); + expect(document.body.textContent).toContain('newWorkspace.signInForDevices'); + }); + + it('creates the personal assistant on this machine', async () => { + const order: string[] = []; + const selectDevice = vi.fn(async (target: string) => { order.push(`select:${target}`); return true; }); + const createAssistant = vi.fn(async () => { order.push('create'); }); + await open({ onSelectDevice: selectDevice, deviceOptions: devices, onCreateAssistant: createAssistant }); + await choose('assistant'); + expect(order).toEqual(['select:local', 'create']); + expect(close).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index ab36ba4028..dddc7fa831 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -1,9 +1,9 @@ /** A named project groups one primary folder and its associated folders. */ import { - ActionCard, Button, Card, CardBody, Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, + ActionCard, Button, Card, CardBody, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, IconButton, Input, OverflowText, Tooltip, } from '@openbitfun/ui'; -import { FolderPlus, Server } from 'lucide-react'; +import { FolderPlus, Loader2, Monitor, MonitorSmartphone, Server, SquareTerminal } from 'lucide-react'; import React, { useState, useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; @@ -20,6 +20,22 @@ import './NewProjectDialog.scss'; const log = createLogger('NewProjectDialog'); +/** Another online host of the account that a project can be created on. */ +export interface NewProjectDeviceOption { + surfaceId: string; + name: string; + hostKind: 'desktop' | 'cli' | null; + /** Platform of a client host, for its label. */ + platform?: 'mac' | 'windows' | 'linux' | null; + /** The window renders this device right now. */ + rendered: boolean; + /** Relay-confirmed mutual control; an incompatible build is listed but disabled. */ + controllable: boolean; +} + +/** `local` is this machine; any other value is a device surface id. */ +export type NewProjectDeviceTarget = 'local' | string; + export interface NewProjectDialogProps { isOpen: boolean; onClose: () => void; @@ -29,11 +45,21 @@ export interface NewProjectDialogProps { onSelectRemoteHost?: (target: WorkspaceConnectionTarget, preset?: WorkspaceConnectionPreset) => Promise | null>; /** Sidebar entry starts with a choice when assistant creation is available. */ onCreateAssistant?: () => Promise; + /** + * Render the device a new item is created on before its form opens, so the + * folder picker, recent workspaces and sources all belong to that device. + * Resolves false when a newer device switch superseded this one. Absent + * keeps the rendered device and hides the other-device choice. + */ + onSelectDevice?: (target: NewProjectDeviceTarget) => Promise; + /** Other online hosts of the account; undefined while signed out. */ + deviceOptions?: readonly NewProjectDeviceOption[]; 'data-testid'?: string; } export const NewProjectDialog: React.FC = ({ - isOpen, onClose, onConfirm, defaultParentPath, onPickRemoteFolder, onSelectRemoteHost, onCreateAssistant, 'data-testid': testId, + isOpen, onClose, onConfirm, defaultParentPath, onPickRemoteFolder, onSelectRemoteHost, onCreateAssistant, + onSelectDevice, deviceOptions, 'data-testid': testId, }) => { const { t } = useI18n('common'); const [folders, setFolders] = useState([]); @@ -48,7 +74,11 @@ export const NewProjectDialog: React.FC = ({ const [isCreating, setIsCreating] = useState(false); const [isSelecting, setIsSelecting] = useState(false); const [error, setError] = useState(''); - const [step, setStep] = useState<'choice' | 'project'>(onCreateAssistant ? 'choice' : 'project'); + const [step, setStep] = useState<'choice' | 'device' | 'project'>(onCreateAssistant ? 'choice' : 'project'); + /** Device chosen through the other-device entry; null for this machine. */ + const [projectDevice, setProjectDevice] = useState(null); + const [selectingDevice, setSelectingDevice] = useState(null); + const deviceChoiceRef = useRef(null); const nameInputRef = useRef(null); const assistantChoiceRef = useRef(null); const submissionPending = useRef(false); @@ -57,7 +87,8 @@ export const NewProjectDialog: React.FC = ({ const formId = useId(); const sourceLabelId = useId(); const nameHintId = useId(); - const busy = isCreating || isSelecting; + const busy = isCreating || isSelecting || selectingDevice !== null; + const offersDevices = Boolean(onCreateAssistant && onSelectDevice); const name = projectName.trim(); const needsName = folders.length !== 1; const invalidDirectoryName = folders.length === 0 && (/[\\/]/.test(name) || name === '.' || name === '..'); @@ -91,6 +122,8 @@ export const NewProjectDialog: React.FC = ({ setSelectedHostOption(undefined); setConnectedRemote(undefined); setError(''); + setProjectDevice(null); + setSelectingDevice(null); setStep(onCreateAssistant ? 'choice' : 'project'); } wasOpen.current = isOpen; @@ -99,6 +132,7 @@ export const NewProjectDialog: React.FC = ({ useEffect(() => { if (!isOpen) return; if (step === 'project') nameInputRef.current?.focus(); + else if (step === 'device') deviceChoiceRef.current?.focus(); else assistantChoiceRef.current?.focus(); }, [isOpen, step]); @@ -135,6 +169,43 @@ export const NewProjectDialog: React.FC = ({ setStep(next); }; + /** + * Render the target device, then continue there. Everything the project form + * reads afterwards (folders, recent workspaces, sources) belongs to it. + */ + const enterDevice = async (target: NewProjectDeviceTarget, device: NewProjectDeviceOption | null): Promise => { + if (!onSelectDevice) return true; + setSelectingDevice(target); + setError(''); + try { + if (!(await onSelectDevice(target))) return false; + formSurface.current = getActiveSurfaceScope(); + setProjectDevice(device); + setFolders([]); + setSource('host'); + setSelectedOption(undefined); + setSelectedHostOption(undefined); + setConnectedRemote(undefined); + return true; + } catch (cause) { + log.error('Failed to render device for creation', cause); + setError(cause instanceof Error && cause.message ? cause.message : t('newWorkspace.deviceError')); + return false; + } finally { + setSelectingDevice(null); + } + }; + + const handleChooseLocalProject = async () => { + if (busy || submissionPending.current) return; + if (await enterDevice('local', null)) setStep('project'); + }; + + const handleChooseDevice = async (device: NewProjectDeviceOption) => { + if (busy || submissionPending.current || !device.controllable) return; + if (await enterDevice(device.surfaceId, device)) setStep('project'); + }; + const handleAddFolder = useCallback(async () => { if (busy || sourceOptions.loading || !sourceReady) return; setIsSelecting(true); @@ -172,6 +243,11 @@ export const NewProjectDialog: React.FC = ({ setIsCreating(true); setError(''); try { + if (onSelectDevice) { + // The personal assistant always lives on this machine. + if (!(await onSelectDevice('local'))) return; + formSurface.current = getActiveSurfaceScope(); + } formSurface.current.assertCurrent('create assistant'); await onCreateAssistant(); formSurface.current.assertCurrent('finish assistant creation'); @@ -183,7 +259,7 @@ export const NewProjectDialog: React.FC = ({ submissionPending.current = false; setIsCreating(false); } - }, [busy, onCreateAssistant, onClose, t]); + }, [busy, onCreateAssistant, onClose, onSelectDevice, t]); const handleConfirm = useCallback(async () => { if (busy || sourceOptions.loading || !sourceReady || submissionPending.current || step !== 'project' || (needsName && !name) || invalidDirectoryName) return; @@ -294,24 +370,28 @@ export const NewProjectDialog: React.FC = ({ {options.error && {options.error}}
; - const projectTitle = t(onCreateAssistant ? 'newWorkspace.createProject' : 'newProject.title'); + const projectTitle = projectDevice + ? t('newWorkspace.createProjectOn', { name: projectDevice.name }) + : t(onCreateAssistant ? 'newWorkspace.createProject' : 'newProject.title'); + const dialogTitle = step === 'choice' ? t('newWorkspace.title') + : step === 'device' ? t('newWorkspace.chooseDevice') : projectTitle; return ( { if (!busy) onClose(); }} - initialFocusRef={step === 'choice' ? assistantChoiceRef : nameInputRef} + initialFocusRef={step === 'choice' ? assistantChoiceRef : step === 'device' ? deviceChoiceRef : nameInputRef} size="md" data-testid={testId}> - {step === 'choice' ? t('newWorkspace.title') : projectTitle} + {dialogTitle} - {step === 'choice' && {t('newWorkspace.description')}} {step === 'choice' && ( -
+
} aria-label={t('newWorkspace.createAssistant')} aria-busy={isCreating} disabled={busy} @@ -326,14 +406,64 @@ export const NewProjectDialog: React.FC = ({ )} /> } - aria-label={t('newWorkspace.createProject')} disabled={busy} - onClick={() => changeStep('project')} data-testid="nav-session-group-add-project" + aria-label={t('newWorkspace.createProject')} aria-busy={selectingDevice === 'local'} disabled={busy} + onClick={() => { if (offersDevices) void handleChooseLocalProject(); else changeStep('project'); }} + data-testid="nav-session-group-add-project" body={( - {t('newWorkspace.projectTitle')} - {t('newWorkspace.projectDescription')} + {t(offersDevices ? 'newWorkspace.localProjectTitle' : 'newWorkspace.projectTitle')} + + {selectingDevice === 'local' ? t('newWorkspace.connecting') + : t(offersDevices ? 'newWorkspace.localProjectDescription' : 'newWorkspace.projectDescription')} + )} /> + {offersDevices && ( + } + aria-label={t('newWorkspace.createDeviceProject')} disabled={busy} + onClick={() => changeStep('device')} data-testid="nav-session-group-add-device-project" + body={( + + {t('newWorkspace.deviceProjectTitle')} + {t('newWorkspace.deviceProjectDescription')} + + )} /> + )} +
+ )} + {step === 'device' && ( +
+ {deviceOptions === undefined && {t('newWorkspace.signInForDevices')}} + {deviceOptions?.length === 0 && {t('newWorkspace.noOtherDevices')}} + {deviceOptions?.map((device, index) => { + // The same tag the sidebar shows: CLI, or the client's platform. + const hostLabel = device.hostKind === 'cli' ? t('nav.devices.hostCli') + : device.platform ? t(`nav.devices.platform.${device.platform}`) : null; + const connecting = selectingDevice === device.surfaceId; + const meta = connecting ? t('newWorkspace.connecting') + : !device.controllable ? t('deviceOverview.deviceClientIncompatible') + : [hostLabel, device.rendered ? t('newWorkspace.deviceCurrent') : null].filter(Boolean).join(' · '); + return ( +
+ } + aria-label={t('newWorkspace.createProjectOn', { name: device.name })} aria-busy={connecting} + disabled={busy || !device.controllable} onClick={() => { void handleChooseDevice(device); }} + data-testid="new-project-device-option" data-device-surface={device.surfaceId} + body={( + + {device.name} + {meta && {meta}} + + )} /> +
+ ); + })}
)} {step === 'project' && ( @@ -375,7 +505,6 @@ export const NewProjectDialog: React.FC = ({ if (workspaceSourceCoordinates(value).location !== location) setSelectedHostOption(undefined); setSource(value); setSelectedOption(undefined); setConnectedRemote(undefined); setError(''); }} /> - {hasFolders && {t('workspaceSource.foldersLockedHint')}}
)}
@@ -418,17 +547,25 @@ export const NewProjectDialog: React.FC = ({ )} {error &&
{error}
} + {step === 'device' && ( + + + + )} {step === 'project' && ( {onCreateAssistant && ( - )}
{!onCreateAssistant && }
diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index ccf6767665..b97f0a0d70 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -66,6 +66,13 @@ } } + // The account mark is lit like a device disc, without an outline. + &__avatar { + border: 0; + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 7%, transparent); + color: var(--openbitfun-color-content-primary); + } + &__identity-copy { @include surface.row-copy; } @@ -139,14 +146,21 @@ padding: var(--openbitfun-space-3) surface.$row-inline; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + // An offline device recedes; nothing else changes colour. + &.offline .account-panel__device-glyph { color: var(--openbitfun-color-content-muted); } + & + & { - @include surface.card-divider; + @include surface.card-divider(calc(40px + #{surface.$row-gap})); } &.selectable:has(> .account-panel__device-select:hover) { - background: var(--openbitfun-color-action-neutral-surface); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 4%, transparent); } + // Row actions stay quiet until the row is in hand. + // Row actions appear with the row in hand; at rest the list is just names. + &:not(:hover, :focus-within) .account-panel__device-action { opacity: 0; } + &.selectable:has(> .account-panel__device-select:active) { background: var(--openbitfun-color-action-neutral-surface-pressed); } @@ -192,7 +206,26 @@ } &__device-glyph { - @include surface.icon-slot; + @include surface.icon-disc(--_account-light); + } + + // This machine: a small home glyph after its name, not a second badge. + &__device-home { + display: inline-flex; + flex-shrink: 0; + color: var(--openbitfun-color-content-muted); + } + + &__refresh { + color: var(--openbitfun-color-content-secondary); + + &:hover { color: var(--openbitfun-color-content-primary); } + } + + &__device-action { + --_icon-button-size: 24px; + --_icon-button-icon-size: 12px; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } &__device-info { @@ -271,16 +304,39 @@ display: flex; min-width: 0; align-items: center; - gap: var(--openbitfun-space-2); - - > [data-openbitfun-component='icon-button'] { - flex-shrink: 0; - } } + // Editing keeps the name's type and place: an underline in the row's light. &__device-alias-input { flex: 1 1 auto; min-inline-size: 0; + gap: var(--openbitfun-space-1); + padding-inline: 0; + border: 0; + border-block-end: var(--openbitfun-border-width-default) solid color-mix(in srgb, var(--openbitfun-color-content-secondary) 35%, transparent); + border-radius: 0; + + &, + &:hover, + &:focus-within { background: transparent; } + + &:focus-within { border-block-end-color: color-mix(in srgb, var(--openbitfun-color-content-secondary) 80%, transparent); } + + input { + @include surface.row-title; + caret-color: var(--openbitfun-color-content-secondary); + } + + input::placeholder { color: var(--openbitfun-color-content-muted); } + } + + &__device-alias-action { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-background-hover: color-mix(in srgb, var(--openbitfun-color-content-secondary) 18%, transparent); + border-radius: var(--openbitfun-radius-pill); + + &:first-child { --_icon-button-content: color-mix(in srgb, var(--openbitfun-color-content-secondary) 72%, var(--openbitfun-color-content-primary)); } } &__loading-overlay { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx index f208f47741..a70a358098 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -210,9 +210,9 @@ it('adopts the account-bound local device ID without reconnecting', async () => mocks.getDeviceInfo.mockReset().mockResolvedValueOnce({ device_id: 'before-auth' }) .mockReturnValueOnce(new Promise(resolve => { finishInfo = resolve; })); await act(async () => { root.render( {}} />); }); - expect(container.textContent).not.toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).toBeNull(); await act(async () => { finishInfo({ device_id: 'local' }); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1); expect(mocks.accountStatus).toHaveBeenCalledTimes(1); }); @@ -251,9 +251,9 @@ it('ignores pre-auth device information that arrives after the adopted ID', asyn .mockReturnValueOnce(new Promise(resolve => { finishOldInfo = resolve; })) .mockResolvedValueOnce({ device_id: 'local' }); await act(async () => { root.render( {}} />); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); await act(async () => { finishOldInfo({ device_id: 'before-auth' }); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index 649bdc2c5c..e5b5f4cb41 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -1,13 +1,13 @@ import { refreshDeviceDirectory, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; /** Account login and authenticated device connections. */ -import { OverflowText, Alert, Avatar, Button, Icon, IconButton, Input, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { OverflowText, Alert, Avatar, Button, Icon, IconButton, Input, ScrollArea } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { confirmDanger, } from '@/infrastructure/confirm-dialog'; -import { LogOut, Pencil, Check, X } from 'lucide-react'; +import { LogOut, Pencil, Check, X, House } from 'lucide-react'; import { DeviceSystemGlyph } from '../NavPanel/components/DeviceSystemGlyph'; import { reportedHostKind } from '../NavPanel/deviceInterconnectionOverview'; import { remoteConnectAPI, deviceDisplayName, deviceMetadataLabel } from '@/infrastructure/api/service-api/RemoteConnectAPI'; @@ -868,14 +868,14 @@ export const AccountPanel: React.FC = ({
{renderSectionHeading('account-panel-account-title', t('accountLogin.accountSectionTitle'))}
- + {username.trim().charAt(0).toUpperCase() || } {username.trim()} {t('accountLogin.signedInAccount')} -
@@ -883,7 +883,7 @@ export const AccountPanel: React.FC = ({
{renderSectionHeading('account-panel-devices-title', t('accountLogin.linkedDevices'), ( relayFailure && failureBannerAction(relayFailure) ? null : ( - @@ -959,40 +959,52 @@ export const AccountPanel: React.FC = ({ name next to an editor reads as two device names. */} {editingDeviceId === d.device_id ? ( + {/* The name itself becomes editable, as on the device card. */} setAliasDraft(e.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); void handleUpdateAlias(d); } if (event.key === 'Escape') { event.preventDefault(); setEditingDeviceId(null); } }} - placeholder={t('accountLogin.deviceAliasPlaceholder')} + onFocus={event => event.currentTarget.select()} + placeholder={d.device_name ?? t('accountLogin.deviceAliasPlaceholder')} + title={t('accountLogin.deviceAliasPlaceholder')} aria-label={t('accountLogin.deviceAlias')} autoFocus disabled={savingAliasId === d.device_id} - /> - } - loading={savingAliasId === d.device_id} - onClick={() => void handleUpdateAlias(d)} - size="sm" - variant="primary" - /> - } - onClick={() => setEditingDeviceId(null)} - size="sm" - variant="quiet" + trailing={<> + } + loading={savingAliasId === d.device_id} + onClick={() => void handleUpdateAlias(d)} + size="xs" + variant="quiet" + /> + } + onClick={() => setEditingDeviceId(null)} + size="xs" + variant="quiet" + /> + } /> ) : ( {displayName} - {isLocal && {t('accountLogin.thisDevice')}} + {isLocal && ( + + + + )} )} @@ -1018,20 +1030,22 @@ export const AccountPanel: React.FC = ({ {editingDeviceId !== d.device_id && ( } + icon={} onClick={(e) => { e.stopPropagation(); handleStartAliasEdit(d); }} - size="sm" + size="xs" title={t('accountLogin.editDeviceAlias')} variant="quiet" /> } + icon={} onClick={(e) => { e.stopPropagation(); handleDeleteDevice(d.device_id, displayName); }} - size="sm" + size="xs" title={removeLabel} variant="quiet" /> diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx new file mode 100644 index 0000000000..5aae3407e5 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export type ConnectionStateTone = 'success' | 'warning' | 'neutral'; + +/** + * A connection state as the device card draws it: a coloured dot and a quiet + * label. A live link breathes, like the card's in-use signal. + */ +export function ConnectionStateMark({ tone, live = tone === 'success', children }: { + tone: ConnectionStateTone; + live?: boolean; + children: React.ReactNode; +}) { + return ( + + + ); +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index d5a29d0ca9..2868d48b1d 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -134,8 +134,8 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).not.toContain('role="link"'); expect(dialogSource).toContain('className="openbitfun-remote-connect__inline-link" onClick={handleOpenFeishuPlatform}'); - expect(dialogSource).toContain('
-
@@ -1326,7 +1329,7 @@ export const RemoteConnectDialog: React.FC = ({ {statusDetail} )} - {statusLabel} + {statusLabel}
); } + +/** The history loading skeleton, shared with presentations that precede a session (a device switch). */ +export function HistorySessionSkeleton() { + return ( + + ); +} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 0b27526459..f23546d6c3 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -520,7 +520,11 @@ export const ModernFlowChatContainer: React.FC = ( const { requests: permissionRequests } = usePermissionRequests(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); const [queuedTurnNavigation, setQueuedTurnNavigation] = useState(null); - const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); + // A view mounted mid-open (for example by a device switch) adopts it. + const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(() => { + const transition = getHistorySessionOpenTransitionSnapshot(); + return transition ? { sessionId: transition.sessionId, sessionTitle: transition.sessionTitle, maxMs: transition.maxMs } : null; + }); const [searchOpenRequest, setSearchOpenRequest] = useState(0); const backgroundCommandActivities = useBackgroundCommandActivityStore(state => state.activities); const [stoppingBackgroundCommandIds, setStoppingBackgroundCommandIds] = useState>(() => new Set()); @@ -885,6 +889,7 @@ export const ModernFlowChatContainer: React.FC = ( setPendingHistoryOpenSession({ sessionId: detail.sessionId, sessionTitle: detail.sessionTitle, + maxMs: detail.maxMs, }); startupTrace.markPhase('historical_session_open_intent_overlay', { sessionId: detail.sessionId, @@ -917,7 +922,7 @@ export const ModernFlowChatContainer: React.FC = ( } return current; }); - }, 4000); + }, pendingHistoryOpenSession.maxMs ?? 4000); return () => { window.clearTimeout(timeoutId); @@ -933,9 +938,9 @@ export const ModernFlowChatContainer: React.FC = ( return; } - if (historyState === 'failed') { - clearHistorySessionOpenTransition(pendingHistoryOpenSession.sessionId); - } + // Settled either way: the open's transition ends now, not at its timeout + // (a cross-device open allows up to 20 s, and its readers defer work). + clearHistorySessionOpenTransition(pendingHistoryOpenSession.sessionId); setPendingHistoryOpenSession(null); }, [ historyState, @@ -1243,6 +1248,25 @@ export const ModernFlowChatContainer: React.FC = ( const showHistoryLoadingLayer = !showHistoryOpenIntentOverlay && !showFailedHistoryPlaceholder && (showHistoryPlaceholder || activeHistoryRestore?.status === 'loading'); + // Switching between sessions whose transcripts are already loaded settles + // the new one into place instead of cutting to it; a session still loading + // gets the loading boundary's own hand-over. + const messagesRef = useRef(null); + const shownSessionIdRef = useRef(null); + useLayoutEffect(() => { + const sessionId = activeSession?.sessionId ?? null; + const previous = shownSessionIdRef.current; + shownSessionIdRef.current = sessionId; + const element = messagesRef.current; + if (!element || !previous || !sessionId || previous === sessionId) return; + if (showHistoryLoadingLayer || showHistoryOpenIntentOverlay || virtualItems.length === 0) return; + if (typeof element.animate !== 'function' + || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; + element.animate( + [{ opacity: 0.35, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], + { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' }, + ); + }, [activeSession?.sessionId, showHistoryLoadingLayer, showHistoryOpenIntentOverlay, virtualItems.length]); const historyLoadingIdentity = surfaceScope.key('history-loading', surfaceScope.epoch, showHistoryOpenIntentOverlay ? pendingHistoryOpenSession?.sessionId : activeSession?.sessionId); const isHistoryContentPresented = historyLoadingPresentation?.identity === historyLoadingIdentity && historyLoadingPresentation.ready; @@ -2774,6 +2798,7 @@ export const ModernFlowChatContainer: React.FC = ( />
* { + animation: history-paging-reveal 160ms ease-out 200ms both; + } } &__history-paging-spinner { @@ -249,3 +254,10 @@ mask-image: none; } } + +// A wait that ends quickly shows nothing: the indicator fades in only once +// the wait has lasted long enough to be noticed. +@keyframes history-paging-reveal { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 1d5e11318b..ca9570378a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -1804,9 +1804,11 @@ describe('VirtualMessageList natural scroll contract', () => { prependOlderTurns(3); - // Refused, because the holder owns a target of its own... - expect(scroller.scrollTop).toBe(500); - // ...and asked to go and reach it. + // Held still before paint, as the holder itself, so no frame shows + // the arrived history pushing the reader's Turn down... + expect(scroller.scrollTop).toBe(620); + expect(mocks.viewportOwner?.currentOwner()).toBe('follow-output'); + // ...and the holder is asked to go and reach its own target. expect(mocks.scheduleFollowToLatest).toHaveBeenCalled(); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index cca57c6617..9ace9e4499 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -946,7 +946,21 @@ const VirtualMessageListSession = forwardRef 0 && viewportOwner.currentOwner() === 'follow-output'; - if (wokeFollowOutput) scheduleFollowToLatest(); + if (wokeFollowOutput) { + /* + * Hold the content still in this frame, before paint. Waking the loop + * alone leaves one painted frame at the old offset, where the history + * that just arrived pushes the reader's Turn down and the next frame + * snaps it back: the jolt a late older page caused at the tail. The + * write is made as follow-output, the owner that refused the shift, so + * the loop's own target stays authoritative afterwards. + */ + if (viewportOwner.write({ owner: 'follow-output', topPx: fromPx + shiftedPx })) { + virtualizer.syncViewportOffset(scroller.scrollTop); + viewportAnchor.absorbViewportShift(shiftedPx); + } + scheduleFollowToLatest(); + } }, [ readContentEndScrollTop, scheduleFollowToLatest, diff --git a/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx index 9f84923b37..515c752776 100644 --- a/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx +++ b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx @@ -8,6 +8,7 @@ import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPos import { formatTokenCount, formatUsagePercent } from './usageReportUtils'; import { useUsageSeriesHighlight } from './useUsageSeriesHighlight'; import { useContextPreview } from './useContextPreview'; +import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal'; import './ContextUsagePopover.scss'; const CATEGORIES = [ @@ -39,7 +40,9 @@ export function ContextUsagePopover({ ...previewSession, reportSessionId: previewSession?.sessionId, }); const [context, setContext] = useState(); - const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(loadContext ? 'loading' : 'ready'); + const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'unsupported' | 'updateRequired'>( + loadContext ? 'loading' : 'ready', + ); // Snapshot the loader on open: live token updates must not re-fetch the report. const loaderRef = useRef(loadContext); useEffect(() => { @@ -47,7 +50,12 @@ export function ContextUsagePopover({ if (loaderRef.current) { void loaderRef.current().then(value => { if (current) { setContext(value); setStatus('ready'); } - }, () => { if (current) setStatus('error'); }); + }, (error: unknown) => { + if (!current) return; + // A peer host without the report is an expected state, not a load failure. + const refusal = classifyPeerHostRefusal(error); + setStatus(refusal ? (refusal.kind === 'updateRequired' ? 'updateRequired' : 'unsupported') : 'error'); + }); } return () => { current = false; }; }, []); @@ -154,7 +162,11 @@ export function ContextUsagePopover({ :
} imageSize="sm" title={ - {t(status === 'loading' ? 'usage.context.loading' : status === 'error' ? 'usage.context.loadFailed' : 'usage.context.unavailable')} + {status === 'loading' ? t('usage.context.loading') + : status === 'error' ? t('usage.context.loadFailed') + : status === 'unsupported' ? t('usage.context.unsupported') + : status === 'updateRequired' ? t('usage.context.updateRequired') + : t('usage.context.unavailable')} } />
} diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index 94e5ea34f1..330c2bfb29 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -982,6 +982,20 @@ describe('Session usage report UI components', () => { expect(container.querySelector('button[aria-label="Copy Markdown"]')).not.toBeNull(); }); + it('shows a host without the usage report as a calm unsupported state instead of a failure', () => { + render(); + + expect(container.textContent).toContain('Context usage is not available on this device.'); + expect(container.textContent).not.toContain('usage.panel.failed'); + }); + + it('localizes the unsupported context usage copy in every shipped locale', () => { + for (const locale of [enFlowChat, zhCnFlowChat, zhTwFlowChat]) { + expect(locale.usage.context.unsupported).toBeTruthy(); + expect(locale.usage.context.updateRequired).toBeTruthy(); + } + }); + it('shows an immediate usage loading card before report data exists', () => { render( void; @@ -37,6 +39,7 @@ export const SessionUsagePanel: React.FC = ({ sessionTitle, isLoading = false, error, + unsupported, workspacePath, workspaceId, onTitleChange, @@ -71,11 +74,13 @@ export const SessionUsagePanel: React.FC = ({ } }, [markdown, redactExportPaths, report, t]); - if (isLoading || error) { + if (isLoading || error || unsupported) { return (
{isLoading ? ( {t('usage.loading.title')} + ) : unsupported ? ( + ) : ( )} diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index bd5af6e746..59a00f0bdd 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -45,6 +45,7 @@ import { createChatSession as createChatSessionModule, hydrateSessionHistoryForDetail as hydrateSessionHistoryForDetailModule, preloadHistoricalSessionForOpen as preloadHistoricalSessionForOpenModule, + prewarmHistoricalSession as prewarmHistoricalSessionModule, switchChatSession as switchChatSessionModule, deleteChatSession as deleteChatSessionModule, archiveChatSession as archiveChatSessionModule, @@ -145,8 +146,8 @@ export class FlowChatManager { registerDriverSessionLookup( sessionId => this.context.flowChatStore.getState().sessions.get(sessionId), ); - this.context.flowChatStore.registerPersistUnreadCompletionCallback((sessionId, value) => { - updateSessionMetadata(this.context, sessionId, ['unreadCompletion', 'needsUserAttention']).catch(err => { + this.context.flowChatStore.registerPersistUnreadCompletionCallback((sessionId, value, receipt) => { + updateSessionMetadata(this.context, sessionId, ['unreadCompletion', 'needsUserAttention'], { unreadReceipt: receipt }).catch(err => { log.warn('Failed to persist unread completion change', { sessionId, value, err }); }); }); @@ -683,6 +684,10 @@ export class FlowChatManager { return switchChatSessionModule(this.context, sessionId, isStillRelevant); } + prewarmHistoricalSession(sessionId: string): void { + prewarmHistoricalSessionModule(this.context, sessionId); + } + preloadHistoricalSessionForOpen(sessionId: string): void { preloadHistoricalSessionForOpenModule(this.context, sessionId); } diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts index a62043d0a7..93172932cb 100644 --- a/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts @@ -22,7 +22,7 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConn subscribeSession: fixture.subscribe, unsubscribeSession: fixture.unsubscribe, loadOlderSession: fixture.older, } })); -import { RelaySessionHistory } from './RelaySessionHistory'; +import { prewarmRelaySessionHistory, RelaySessionHistory } from './RelaySessionHistory'; describe('RelaySessionHistory', () => { beforeEach(() => { vi.clearAllMocks(); fixture.current = true; }); @@ -54,6 +54,37 @@ describe('RelaySessionHistory', () => { expect(fixture.record).toBeNull(); }); + it('adopts a stream warmed before the device was rendered, with what it buffered', async () => { + let resolveSubscription!: (id: string) => void; + fixture.subscribe.mockImplementation(() => new Promise(resolve => { resolveSubscription = resolve; })); + prewarmRelaySessionHistory('host', 'session'); + // The host answers while the device switch is still running. + fixture.record?.({ sessionId: 'session', id: 'turn/turn' }); + fixture.record?.({ sessionId: 'other', id: 'turn/other' }); + fixture.ready?.({ sessionId: 'session', hasMore: false, oldestSeq: 1, cursor: 1 }); + const apply = vi.fn(); + const onReady = vi.fn(); + const history = new RelaySessionHistory('session', apply, onReady, vi.fn()); + const opened = history.open(); + resolveSubscription('warm-subscription'); + await opened; + expect(fixture.subscribe).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledWith({ sessionId: 'session', id: 'turn/turn' }); + expect(onReady).toHaveBeenCalledOnce(); + history.close(); + expect(fixture.unsubscribe).toHaveBeenCalledWith('warm-subscription'); + }); + + it('closes a warm stream nobody adopted', async () => { + vi.useFakeTimers(); + fixture.subscribe.mockResolvedValue('unused'); + prewarmRelaySessionHistory('host', 'abandoned'); + await vi.advanceTimersByTimeAsync(30_000); + expect(fixture.unsubscribe).toHaveBeenCalledWith('unused'); + expect(fixture.record).toBeNull(); + }); + it('renders pending records, then resyncs only for a gap the controller replays', async () => { vi.useFakeTimers(); fixture.subscribe.mockImplementation(async () => { diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts index 4b018bf747..a8ca0ce3dd 100644 --- a/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts @@ -33,6 +33,69 @@ export class RelayHistoryFirstPageOverdueError extends Error { } } +/** + * A session stream opened before its device is rendered: a cross-device open + * starts it on click, so the host reads the session while the switch runs. + * Events are buffered until the session's history owner adopts the stream. + */ +interface WarmSessionStream { + subscription: Promise; + events: Array<{ kind: 'record' | 'gap' | 'syncError' | 'ready' | 'interaction'; value: unknown }>; + dispose: () => void; + timer: ReturnType; +} + +/** A warm stream nobody adopted (the open failed or moved on) closes after this. */ +const WARM_STREAM_TTL_MS = 30_000; +const warmStreams = new Map(); +const warmKey = (surfaceId: string, sessionId: string) => `${surfaceId}\u0000${sessionId}`; + +/** Start reading `sessionId` on `surfaceId` (a peer) before that device is rendered. */ +export function prewarmRelaySessionHistory(surfaceId: string, sessionId: string): void { + const key = warmKey(surfaceId, sessionId); + if (warmStreams.has(key)) return; + const events: WarmSessionStream['events'] = []; + const forThisSession = (value: unknown) => (value as { sessionId?: unknown } | null)?.sessionId === sessionId; + const disposers = [ + remoteConnectAPI.onSessionRecord(value => { if (forThisSession(value)) events.push({ kind: 'record', value }); }), + remoteConnectAPI.onSessionGap(value => { if (forThisSession(value)) events.push({ kind: 'gap', value }); }), + remoteConnectAPI.onSessionSyncError(value => { if (forThisSession(value)) events.push({ kind: 'syncError', value }); }), + remoteConnectAPI.onSessionReady(value => { if (forThisSession(value)) events.push({ kind: 'ready', value }); }), + remoteConnectAPI.onSessionInteractionChanged(value => { if (forThisSession(value)) events.push({ kind: 'interaction', value }); }), + ]; + const subscription = remoteConnectAPI.subscribeSession(surfaceId, sessionId); + const stream: WarmSessionStream = { + subscription, + events, + dispose: () => disposers.splice(0).forEach(dispose => dispose()), + timer: setTimeout(() => { + if (warmStreams.get(key) !== stream) return; + warmStreams.delete(key); + stream.dispose(); + void subscription.then(id => remoteConnectAPI.unsubscribeSession(id)).catch(() => undefined); + }, WARM_STREAM_TTL_MS), + }; + // A failed warm read is not an error: the owner opens its own stream. + void subscription.catch(error => { + if (warmStreams.get(key) !== stream) return; + warmStreams.delete(key); + clearTimeout(stream.timer); + stream.dispose(); + log.debug('Session stream prewarm failed', { sessionId, error }); + }); + warmStreams.set(key, stream); +} + +function takeWarmStream(surfaceId: string, sessionId: string): WarmSessionStream | undefined { + const key = warmKey(surfaceId, sessionId); + const stream = warmStreams.get(key); + if (!stream) return undefined; + warmStreams.delete(key); + clearTimeout(stream.timer); + stream.dispose(); + return stream; +} + export interface RelayHistoryReady { sessionId: string; hasMore: boolean; @@ -63,6 +126,8 @@ export class RelaySessionHistory { private flushTimer: ReturnType | null = null; private readonly fail: (error: unknown) => void; private readonly resumeInteraction: (() => Promise) | undefined; + private readonly warm: WarmSessionStream | undefined; + private readonly handlers: Partial void>> = {}; /** * `onRecord` must only reduce the record: the host sends each record of a page @@ -83,8 +148,10 @@ export class RelaySessionHistory { ) { this.fail = error => { this.rejectReady(error); this.rejectPage?.(error); onError(error); }; this.resumeInteraction = onResumed; + // Taken before this owner listens, so no event is both buffered and live. + this.warm = takeWarmStream(this.scope.surfaceId, sessionId); // Register before asking Rust to replay its cache or fetch the first page. - this.listeners.push(remoteConnectAPI.onSessionRecord(value => { + this.listeners.push(remoteConnectAPI.onSessionRecord(this.handlers.record = value => { if (!this.isCurrent()) return; const record = value as SessionRecord; if (record.sessionId !== this.sessionId) return; @@ -97,18 +164,18 @@ export class RelaySessionHistory { // sends no tombstones for what it dropped (a rollback, undo, or history // rewrite). Records already received are rendered first; the replay that // follows, up to its ready event, is then the whole truth. - this.listeners.push(remoteConnectAPI.onSessionGap(gap => { + this.listeners.push(remoteConnectAPI.onSessionGap(this.handlers.gap = gap => { if (!this.isCurrent() || gap.sessionId !== this.sessionId || gap.resync !== true) return; this.flush(); try { onResync?.(); } catch (error) { this.fail(error); } })); - this.listeners.push(remoteConnectAPI.onSessionSyncError(error => { + this.listeners.push(remoteConnectAPI.onSessionSyncError(this.handlers.syncError = error => { if (!this.isCurrent() || error.sessionId !== this.sessionId || error.targetDeviceId !== this.scope.surfaceId) return; // Records received before the failure stay rendered. this.flush(); this.fail(new Error(error.message)); })); - this.listeners.push(remoteConnectAPI.onSessionReady(ready => { + this.listeners.push(remoteConnectAPI.onSessionReady(this.handlers.ready = ready => { if (!this.isCurrent() || ready.sessionId !== this.sessionId) return; if (!this.flush()) return; const resumed = this.pageApplied === null; @@ -120,7 +187,7 @@ export class RelaySessionHistory { this.resolveReady(); this.pageApplied?.(); })); - this.listeners.push(remoteConnectAPI.onSessionInteractionChanged(event => { + this.listeners.push(remoteConnectAPI.onSessionInteractionChanged(this.handlers.interaction = event => { if (!this.isCurrent() || event.sessionId !== this.sessionId || !onResumed) return; void onResumed().catch(error => log.warn('Session interaction mailbox refresh failed', {sessionId:this.sessionId,error})); })); @@ -172,7 +239,12 @@ export class RelaySessionHistory { this.scope.assertCurrent('open relay session history'); if (this.closed) throw new Error('Session subscription is closed'); try { - const id = await remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId); + // A stream warmed by the click: what it buffered is applied first, in order. + const warm = this.warm; + if (warm) for (const event of warm.events.splice(0)) this.handlers[event.kind]?.(event.value); + const id = warm + ? await warm.subscription.catch(() => remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId)) + : await remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId); if (!this.isCurrent()) { await remoteConnectAPI.unsubscribeSession(id); this.scope.assertCurrent('finish opening relay session history'); diff --git a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.test.ts b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.test.ts new file mode 100644 index 0000000000..34bae2dca8 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Listener = (surfaceId: string) => void; +const catalog = vi.hoisted(() => ({ + snapshots: new Map(), + listeners: new Set<(surfaceId: string) => void>(), +})); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: (surfaceId: string) => catalog.snapshots.get(surfaceId) ?? { status: 'idle', workspaces: [], sessions: {}, live: false }, + subscribe: (listener: Listener) => { catalog.listeners.add(listener); return () => { catalog.listeners.delete(listener); }; }, + loadedSurfaces: () => [...catalog.snapshots.keys()], + }, +})); + +import { activateSurface, getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { flowChatStore } from '../store/FlowChatStore'; +import { installBackgroundSurfaceSeeding } from './backgroundSurfaceSeeding'; +import { DEFAULT_SESSION_VIEW_KEY, forgetSurfaceSessionTotals, knownWorkspaceSessionTotal } from './workspaceSessionTotals'; + +const record = (sessionId: string, workspaceId: string, title = sessionId) => ({ + sessionId, sessionName: title, agentType: 'agentic', modelName: 'm', createdAt: 1, lastActiveAt: 1, + turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], workspaceId, +}) as never; + +function publish(surfaceId: string, pages: Record) { + catalog.snapshots.set(surfaceId, { + status: 'ready', live: true, + workspaces: Object.keys(pages).map(id => ({ id, name: id, rootPath: `/${id}`, workspaceKind: 'normal' })), + sessions: Object.fromEntries(Object.entries(pages).map(([id, records]) => [id, { + status: 'ready', sessions: records, records, activities: {}, hasMore: false, total: 8, limit: 8, + }])), + }); + for (const listener of [...catalog.listeners]) listener(surfaceId); +} + +const ids = (surfaceId: string) => [...flowChatStore.getSurfaceSessions(surfaceId).keys()].sort(); + +describe('background surface seeding across a switch', () => { + let uninstall: () => void; + beforeEach(() => { uninstall = installBackgroundSurfaceSeeding(); }); + afterEach(() => { + uninstall(); + if (getActiveSurfaceId() !== 'local') activateSurface('local'); + flowChatStore.discardSurfaceState('peer-seed'); + forgetSurfaceSessionTotals('peer-seed'); + catalog.snapshots.clear(); + }); + + it('draws the sessions the catalog read while the device was rendered once it is left', () => { + activateSurface('peer-seed'); + // The rendered device is kept warm: its pages land while it is rendered, + // for workspaces its live list never read. + publish('peer-seed', { repos: [record('r1', 'repos'), record('r2', 'repos')] }); + expect(ids('peer-seed')).toEqual([]); + + activateSurface('local'); + // No catalog read has landed since the switch, yet the rows are there. + expect(ids('peer-seed')).toEqual(['r1', 'r2']); + expect(knownWorkspaceSessionTotal('peer-seed', 'repos', DEFAULT_SESSION_VIEW_KEY)).toBe(8); + }); + + it('fills only what the container lacks; the catalog read after the switch refreshes the rest', () => { + // Stand-in for what the live list loaded (renamed while rendered). + flowChatStore.seedSurfaceSessions('peer-seed', [{ workspaceId: 'repos', workspacePath: '/repos', sessions: [record('r1', 'repos', 'new title')] }]); + activateSurface('peer-seed'); + publish('peer-seed', { repos: [record('r1', 'repos', 'old title'), record('r2', 'repos')] }); + activateSurface('local'); + expect(ids('peer-seed')).toEqual(['r1', 'r2']); + expect(flowChatStore.getSurfaceSessions('peer-seed').get('r1')?.title).toBe('new title'); + // The same older page notified again is not projected over it. + for (const listener of [...catalog.listeners]) listener('peer-seed'); + expect(flowChatStore.getSurfaceSessions('peer-seed').get('r1')?.title).toBe('new title'); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts new file mode 100644 index 0000000000..1d58fab11f --- /dev/null +++ b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts @@ -0,0 +1,163 @@ +/** + * Projects the device catalog into each background device's FlowChat + * container. + * + * Navigation draws a background device from its container with exactly the + * live list's hierarchy, sort and filters, and a switch finds the device's + * sessions already present. The live list therefore paints the same rows in + * the same order the moment a device is rendered, and opening one of them + * needs no extra read. + */ + +import { getActiveSurfaceId, onSurfaceActivated, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog, type DeviceCatalogSnapshot } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { flowChatStore, type BackgroundSessionPage } from '../store/FlowChatStore'; +import { + DEFAULT_SESSION_VIEW_KEY, + forgetSurfaceSessionTotals, + knownWorkspaceSessionTotal, + rememberWorkspaceSessionTotal, +} from './workspaceSessionTotals'; + +let installs = 0; +/** Page record arrays already projected, per surface; the catalog replaces arrays on change. */ +const seededRecords = new Map>(); +/** Top-level session ids each workspace page listed last time, per surface. */ +const listedIds = new Map>>(); +let teardown: (() => void) | null = null; + +function seenRecords(surfaceId: DeviceSurfaceId): WeakSet { + let seen = seededRecords.get(surfaceId); + if (!seen) seededRecords.set(surfaceId, seen = new WeakSet()); + return seen; +} + +function listedFor(surfaceId: DeviceSurfaceId): Map> { + let listed = listedIds.get(surfaceId); + if (!listed) listedIds.set(surfaceId, listed = new Map()); + return listed; +} + +function pageFor(workspace: DeviceCatalogSnapshot['workspaces'][number], records: BackgroundSessionPage['sessions']): BackgroundSessionPage { + const remote = workspace.workspaceKind === 'remote'; + return { + workspaceId: workspace.id, + workspacePath: workspace.rootPath, + remoteConnectionId: remote ? workspace.connectionId : undefined, + remoteSshHost: remote ? workspace.sshHost : undefined, + sessions: records, + }; +} + +export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { + const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); + if (snapshot.status === 'idle') { + // Catalog reset (account change): nothing of the previous read survives. + flowChatStore.discardSeededSessions(surfaceId); + forgetSurfaceSessionTotals(surfaceId); + seededRecords.delete(surfaceId); + listedIds.delete(surfaceId); + return; + } + // The rendered device is drawn from its live container, which its own reads + // own. Pages read meanwhile stay unprojected (not marked seen), so leaving + // the device can still fill what its live list never loaded + // (`seedLeftSurface`). Marking them here left every workspace the live list + // had not read empty after a switch, under a known total, until the next + // catalog read replaced the arrays. + if (surfaceId === getActiveSurfaceId()) return; + const listed = listedFor(surfaceId); + const removed: string[] = []; + const pages: BackgroundSessionPage[] = []; + const seen = seenRecords(surfaceId); + for (const workspace of snapshot.workspaces) { + const page = snapshot.sessions[workspace.id]; + if (!page || page.status === 'idle') continue; + // A notification for another page, a status flip or a total does not + // re-project rows that were already seeded. + if (seen.has(page.records)) continue; + seen.add(page.records); + // A session the host listed before and no longer lists was deleted or + // archived there (or fell past the loaded rows, where it is not drawn). + // With the complete list, anything absent from it is gone. + if (page.status === 'ready' && !page.error) { + const current = new Set(page.sessions.map(session => session.sessionId)); + const previous = listed.get(workspace.id); + if (previous) for (const sessionId of previous) if (!current.has(sessionId)) removed.push(sessionId); + if (!page.hasMore) { + for (const session of flowChatStore.getSurfaceSessions(surfaceId).values()) { + if (session.workspaceId === workspace.id && !session.parentSessionId && session.sessionKind !== 'subagent' + && !current.has(session.sessionId)) removed.push(session.sessionId); + } + } + listed.set(workspace.id, current); + } + if (page.total !== null) rememberWorkspaceSessionTotal(surfaceId, workspace.id, DEFAULT_SESSION_VIEW_KEY, page.total); + pages.push(pageFor(workspace, page.records)); + } + flowChatStore.seedSurfaceSessions(surfaceId, pages); + flowChatStore.dropSurfaceSessions(surfaceId, removed); +} + +/** + * The window stopped rendering `surfaceId`. Its container holds what the live + * list loaded, which can be less than the catalog pages navigation draws (a + * workspace the live list never read, a switch before its reads landed). Fill + * only the sessions it lacks, from the pages the catalog already holds, so the + * device's rows stay where they were instead of emptying until the catalog + * re-reads the host. + * + * Those pages predate what the live list loaded, so they never overwrite a + * session the container has nor remove one, and they are marked seen: the + * read that follows the switch is what refreshes the device. + */ +export function seedLeftSurface(surfaceId: DeviceSurfaceId): void { + if (surfaceId === getActiveSurfaceId()) return; + const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); + if (snapshot.status === 'idle') return; + const listed = listedFor(surfaceId); + const seen = seenRecords(surfaceId); + const pages: BackgroundSessionPage[] = []; + for (const workspace of snapshot.workspaces) { + const page = snapshot.sessions[workspace.id]; + if (!page || page.status === 'idle' || seen.has(page.records)) continue; + seen.add(page.records); + if (page.status === 'ready' && !page.error) { + listed.set(workspace.id, new Set(page.sessions.map(session => session.sessionId))); + } + // The live list's own total wins; the page's covers a workspace it never read. + if (page.total !== null && knownWorkspaceSessionTotal(surfaceId, workspace.id, DEFAULT_SESSION_VIEW_KEY) === undefined) { + rememberWorkspaceSessionTotal(surfaceId, workspace.id, DEFAULT_SESSION_VIEW_KEY, page.total); + } + pages.push(pageFor(workspace, page.records)); + } + flowChatStore.seedSurfaceSessions(surfaceId, pages, { onlyMissing: true }); +} + +/** Reference-counted; navigation installs it while it lists several devices. */ +export function installBackgroundSurfaceSeeding(): () => void { + installs += 1; + if (installs === 1) { + const unsubscribe = deviceWorkspaceCatalog.subscribe(seedBackgroundSurface); + for (const surfaceId of deviceWorkspaceCatalog.loadedSurfaces()) seedBackgroundSurface(surfaceId); + // Synchronous with the activation commit, before navigation draws the + // device just left from its container. + let rendered = getActiveSurfaceId(); + const unsubscribeActivation = onSurfaceActivated(() => { + const left = rendered; + rendered = getActiveSurfaceId(); + if (left !== rendered) seedLeftSurface(left); + }); + teardown = () => { unsubscribe(); unsubscribeActivation(); }; + } + let released = false; + return () => { + if (released) return; + released = true; + installs -= 1; + if (installs === 0) { + teardown?.(); + teardown = null; + } + }; +} diff --git a/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts b/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts index 81a1645251..54d9ca5c6e 100644 --- a/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts +++ b/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { resolveSessionSceneTarget } from '@/app/services/sessionSceneTarget'; import { appManager } from '@/app/services/AppManager'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; @@ -60,7 +60,7 @@ describe('subagent pane navigation through production stores', () => { activateSurface('local'); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); }); @@ -70,7 +70,7 @@ describe('subagent pane navigation through production stores', () => { clearAgentCanvasForPeerSwitch(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); it('resolves unknown continuation policy without reloading an already complete transcript', async () => { diff --git a/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts b/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts new file mode 100644 index 0000000000..fb81803a35 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const catalog = vi.hoisted(() => ({ + snapshots: {} as Record, + surfaces: [] as string[], +})); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: (surfaceId: string) => catalog.snapshots[surfaceId] ?? { workspaces: [], sessions: {} }, + loadedSurfaces: () => catalog.surfaces, + subscribe: () => () => undefined, + }, +})); + +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import { sessionActivityStore } from '../store/sessionActivityStore'; +import { listBackgroundDeviceSessions, listCrossDeviceSessions } from './crossDeviceSessions'; + +const summary = (sessionId: string, execution: string) => ({ sessionId, execution, pendingApprovals: 0, pendingQuestions: 0 }); + +describe('crossDeviceSessions', () => { + afterEach(() => { + catalog.snapshots = {}; + catalog.surfaces = []; + resetDeviceSurfaceForTest(); + }); + + it('prefers the host summary over observed events, which only fill gaps', () => { + sessionActivityStore.applyRead(sessionActivityStore.beginRead('peer-a'), [summary('s-1', 'running'), summary('s-2', 'running')]); + catalog.snapshots['peer-a'] = { + workspaces: [{ id: 'ws-1' }], + sessions: { 'ws-1': { sessions: [{ sessionId: 's-1' }, { sessionId: 's-2' }], activities: { 's-1': summary('s-1', 'idle') } } }, + }; + const rows = listCrossDeviceSessions('peer-a'); + expect(rows.map(row => [row.session.sessionId, row.activity?.execution])).toEqual([['s-1', 'idle'], ['s-2', 'running']]); + }); + + it('lists every loaded device except the rendered one', () => { + catalog.surfaces = ['local', 'peer-a']; + catalog.snapshots['local'] = { workspaces: [{ id: 'w' }], sessions: { w: { sessions: [{ sessionId: 'l-1' }], activities: {} } } }; + catalog.snapshots['peer-a'] = { workspaces: [{ id: 'w' }], sessions: { w: { sessions: [{ sessionId: 'p-1' }], activities: {} } } }; + activateSurface('peer-a'); + expect(listBackgroundDeviceSessions().map(row => row.session.sessionId)).toEqual(['l-1']); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts b/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts new file mode 100644 index 0000000000..0ee580607f --- /dev/null +++ b/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts @@ -0,0 +1,71 @@ +/** + * Sessions of devices this window is not rendering, with their activity. + * + * The rendered device has live containers. Every other device is known through + * the sidebar device catalog, whose pages carry the host's activity summaries + * and are refreshed by the host's catalog invalidations (turn started, ended, + * state changed). Desktop hosts do not fan agentic events out to controllers, + * so a catalog summary is the freshest fact about such a device; local events + * observed while a peer is rendered only fill sessions the catalog lacks. + */ + +import { getActiveSurfaceId, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { sessionActivityStore } from '../store/sessionActivityStore'; + +export interface CrossDeviceSession { + surfaceId: DeviceSurfaceId; + workspaceId: string; + session: SessionMetadata; + activity?: SessionActivitySummary; +} + +/** Catalog summary first (refreshed from the host), then observed events. */ +export function crossDeviceActivity( + surfaceId: DeviceSurfaceId, + sessionId: string, + catalogSummary: SessionActivitySummary | undefined, +): SessionActivitySummary | undefined { + return catalogSummary ?? sessionActivityStore.get(sessionId, surfaceId)?.summary; +} + +export function listCrossDeviceSessions(surfaceId: DeviceSurfaceId): CrossDeviceSession[] { + const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); + const rows: CrossDeviceSession[] = []; + for (const workspace of snapshot.workspaces) { + const page = snapshot.sessions[workspace.id]; + if (!page) continue; + for (const session of page.sessions) { + rows.push({ + surfaceId, + workspaceId: workspace.id, + session, + activity: crossDeviceActivity(surfaceId, session.sessionId, page.activities[session.sessionId]), + }); + } + } + return rows; +} + +/** Every loaded device except the one the window renders. */ +export function listBackgroundDeviceSessions(): CrossDeviceSession[] { + const rendered = getActiveSurfaceId(); + return deviceWorkspaceCatalog.loadedSurfaces() + .filter(surfaceId => surfaceId !== rendered) + .flatMap(listCrossDeviceSessions); +} + +/** `surfaceId` limits notifications to one device (a device header). */ +export function subscribeCrossDeviceSessions(listener: () => void, surfaceId?: DeviceSurfaceId): () => void { + const unsubscribeCatalog = deviceWorkspaceCatalog.subscribe(changed => { + if (!surfaceId || changed === surfaceId) listener(); + }); + const unsubscribeActivity = sessionActivityStore.subscribe(changed => { + if (changed !== getActiveSurfaceId() && (!surfaceId || changed === surfaceId)) listener(); + }); + return () => { + unsubscribeCatalog(); + unsubscribeActivity(); + }; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index ec07f5c0f0..221ee910d6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -130,6 +130,16 @@ describe('PersistenceModule', () => { }), 'workspace-local', ['unreadCompletion', 'needsUserAttention']); }); + it('names the host receipt when the transcript was never loaded', async () => { + const receipt = { turnId: 'host-turn', turnIndex: 4, status: 'cancelled' as const, executionGeneration: 3, recoveryPending: true }; + await updateSessionMetadata(createContext(createDialogTurn('completed')), SESSION_ID, + ['unreadCompletion', 'needsUserAttention'], { unreadReceipt: receipt }); + expect(mockSaveSessionMetadata).toHaveBeenCalledWith(expect.objectContaining({ + lastTurn: receipt, + customMetadata: expect.objectContaining({ unreadCompletionTarget: receipt }), + }), 'workspace-local', ['unreadCompletion', 'needsUserAttention']); + }); + it('addresses session storage through the owning project for a worktree session', async () => { const turn = createDialogTurn('completed'); const context = createContext(turn, { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 7662510de5..efb670b5fd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -1,3 +1,4 @@ +import type { SessionLastTurn } from '@/shared/types/session-history'; import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering'; /** * Persistence module @@ -557,6 +558,7 @@ export async function updateSessionMetadata( context: FlowChatContext, sessionId: string, fields?: UiSessionMetadataField[], + options?: { unreadReceipt?: SessionLastTurn }, ): Promise { const scope = getActiveSurfaceScope(); try { @@ -585,8 +587,14 @@ export async function updateSessionMetadata( const activity = sessionActivityStore.get(sessionId)?.summary; // lastTurn is a receipt guard in a UI patch, not a write to the host's // execution summary. A newer quiet run must not change its target. - const unreadTurn = activity?.unreadTurn; - if (unreadTurn) metadata.lastTurn = unreadTurn; + // The host accepts an acknowledgement only for its own record of the + // result, so that receipt wins over one rebuilt from (possibly unloaded) + // turns. + const receipt = options?.unreadReceipt ?? activity?.unreadTurn; + if (receipt) { + metadata.lastTurn = receipt; + metadata.customMetadata = { ...(metadata.customMetadata ?? {}), unreadCompletionTarget: receipt }; + } } await sessionAPI.saveSessionMetadata( diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 594f19d4f6..6a0d0c5b88 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -324,6 +324,21 @@ describe('resolveAgentTypeForSessionCreation', () => { await expect(resolveAgentTypeForSessionCreation('Standard', workspace)).resolves.toBe('Standard'); }); + it('issues the default-mode reads together and keeps the project mode when the global read fails', async () => { + const project = createDeferred<{ supported: boolean; defaults: { mode?: string } }>(); + const reader = vi.fn(() => project.promise); + configApiMocks.getConfig.mockRejectedValue(new Error('preference unavailable')); + agentApiMocks.getAvailableModes.mockResolvedValue([{ id: 'Standard' }, { id: 'Ultimate' }]); + const resolved = resolveAgentTypeForSessionCreation(undefined, { id: 'project' } as any, reader); + await Promise.resolve(); + expect(reader).toHaveBeenCalledTimes(1); + expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); + expect(agentApiMocks.getAvailableModes).toHaveBeenCalledTimes(1); + expect(projectDefaults.read).not.toHaveBeenCalled(); + project.resolve({ supported: true, defaults: { mode: 'Ultimate' } }); + await expect(resolved).resolves.toBe('Ultimate'); + }); + it('does not override any explicit mode, including the Standard Harness id', async () => { await expect(resolveAgentTypeForSessionCreation('Cowork', null)).resolves.toBe('Cowork'); await expect(resolveAgentTypeForSessionCreation('Standard', null)).resolves.toBe('Standard'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 28d20e29b8..e4c7b0a6a2 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -18,6 +18,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceScope, + isLocalSurface, isSurfaceChangedError, type SurfaceScope, } from '@/infrastructure/peer-device/deviceSurface'; @@ -373,6 +374,27 @@ export function preloadHistoricalSessionForOpen( }); } +/** + * Start reading a session the pointer rests on, before it is clicked: its tail + * (a few turns) is usually in memory by the time the click lands, so the open + * shows the transcript at once instead of a loading state. The same bounded + * hydrate the open would run; nothing is selected or activated. + */ +export function prewarmHistoricalSession(context: FlowChatContext, sessionId: string): void { + const scope = getActiveSurfaceScope(); + // Another device's history is a live relay subscription, not a cheap read: + // hovering across rows must not open one per row. + if (!isLocalSurface(scope.surfaceId)) return; + if (Array.from(context.pendingHistoryLoads.keys()).some(key => pendingHistoryLoadSessionId(key, scope) === sessionId)) return; + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session || session.externalHistory?.mode === 'follow') return; + if (!shouldHydrateHistoricalSessionBeforeSwitch(session)) return; + void hydrateHistoricalSession(context, sessionId, false).catch(error => { + if (isSurfaceChangedError(error)) return; + log.debug('Historical session prewarm failed', { sessionId, error }); + }); +} + const isAssistantWorkspace = (workspace?: WorkspaceInfo | null): boolean => { return workspace?.workspaceKind === WorkspaceKind.Assistant; }; @@ -388,9 +410,28 @@ const resolveSessionWorkspace = (config: SessionConfig): WorkspaceInfo => { return workspace; }; +export type ProjectDefaultsReader = () => ReturnType; + +/** + * One creation reads the owning project's defaults once: the mode resolver and + * the session driver share the same in-flight read instead of issuing two + * host round trips (each one a relay round trip in Peer Device Mode). + */ +export const createProjectDefaultsReader = (projectId: string | undefined): ProjectDefaultsReader | undefined => { + if (!projectId) return undefined; + let read: ReturnType | undefined; + return () => (read ??= readProjectDefaults(projectId)); +}; + +const settledValue = (result: PromiseSettledResult): T => { + if (result.status === 'rejected') throw result.reason; + return result.value; +}; + export const resolveAgentTypeForSessionCreation = async ( requestedMode: string | undefined, - workspace: WorkspaceInfo | null + workspace: WorkspaceInfo | null, + readOwningProjectDefaults?: ProjectDefaultsReader, ): Promise => { if (isAssistantWorkspace(workspace)) { return 'Claw'; @@ -407,21 +448,28 @@ export const resolveAgentTypeForSessionCreation = async ( try { const projectId = workspace?.worktree && !workspace.worktree.isMain && workspace.workspaceKind !== WorkspaceKind.Remote ? workspace.worktree.mainWorkspaceId : workspace?.id; - const projectMode = projectId ? (await readProjectDefaults(projectId)).defaults.mode : undefined; + const readDefaults = readOwningProjectDefaults ?? createProjectDefaultsReader(projectId); + // The three reads are independent host requests. Issue them together so a + // peer-hosted creation pays one relay round trip instead of three; each + // result is still consulted only where the serial order would have read it. + const [projectResult, preferenceResult, modesResult] = await Promise.allSettled([ + readDefaults ? readDefaults() : Promise.resolve(undefined), + configAPI.getConfig(CHAT_INPUT_MODE_PREFERENCE_CONFIG_PATH, { + skipRetryOnNotFound: true, + }) as Promise, + agentAPI.getAvailableModes({ + workspaceId: workspace?.id, + }), + ]); + const projectMode = settledValue(projectResult)?.defaults.mode; const configuredDefaultMode = projectMode || resolveConfiguredChatInputDefaultModeId( - normalizeChatInputModePreference( - await configAPI.getConfig(CHAT_INPUT_MODE_PREFERENCE_CONFIG_PATH, { - skipRetryOnNotFound: true, - }) as AppFlowChatConfig | undefined, - ), + normalizeChatInputModePreference(settledValue(preferenceResult)), ); if (!configuredDefaultMode) { return 'Standard'; } - const availableModes = await agentAPI.getAvailableModes({ - workspaceId: workspace?.id, - }); + const availableModes = settledValue(modesResult); if (availableModes.some(mode => mode.id === configuredDefaultMode)) { return configuredDefaultMode; } @@ -482,7 +530,8 @@ export async function createChatSession( workspace?.workspaceKind === WorkspaceKind.Remote ? workspace.sshHost?.trim() || undefined : undefined; - const agentType = await resolveAgentTypeForSessionCreation(mode, workspace); + const readOwningProjectDefaults = createProjectDefaultsReader(projectId); + const agentType = await resolveAgentTypeForSessionCreation(mode, workspace, readOwningProjectDefaults); surfaceScope.assertCurrent('resolve session creation mode'); const workspaceCreationKey = workspace.id; const draftId = draft ? crypto.randomUUID() : undefined; @@ -522,6 +571,7 @@ export async function createChatSession( workspaceId: workspace?.id, remoteConnectionId, remoteSshHost, + readProjectDefaults: readOwningProjectDefaults, }); surfaceScope.assertCurrent('finish session creation'); return sessionId; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 945815ef53..92cb4a0b9f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -34,6 +34,7 @@ export { createChatSession, hydrateSessionHistoryForDetail, preloadHistoricalSessionForOpen, + prewarmHistoricalSession, switchChatSession, deleteChatSession, archiveChatSession, diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts index b117995da8..99a4debe69 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts +++ b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts @@ -93,6 +93,7 @@ vi.mock('../store/FlowChatStore', () => ({ sessions, activeSessionId, }), + hasUnpromotedSeed: () => false, ensurePersistedSessionMetadata: (...args: unknown[]) => mocks.ensurePersistedSessionMetadata(...args), addExternalSession: (...args: unknown[]) => mocks.addExternalSession(...args), diff --git a/src/web-ui/src/flow_chat/services/openSessionContextPreview.test.ts b/src/web-ui/src/flow_chat/services/openSessionContextPreview.test.ts index 48108719ea..f5d3ccdcc4 100644 --- a/src/web-ui/src/flow_chat/services/openSessionContextPreview.test.ts +++ b/src/web-ui/src/flow_chat/services/openSessionContextPreview.test.ts @@ -130,6 +130,14 @@ describe('system prompt preview tabs', () => { expect(apiMocks.getSessionUsageReport).not.toHaveBeenCalled(); }); + it('treats a peer host without the usage report as unavailable rather than a failure', async () => { + apiMocks.getSessionUsageReport.mockRejectedValue(new Error( + "command 'get_session_usage_report' is not supported on CLI peer host: the CLI peer host has no handler for this command", + )); + await expect(openSessionContextPreview(params)).resolves.toBe('unavailable'); + expect(tabs()).toHaveLength(0); + }); + it('propagates host errors without creating an empty editor', async () => { apiMocks.getSessionUsageReport.mockRejectedValue(new Error('Host offline')); await expect(openSessionContextPreview(params)).rejects.toThrow('Host offline'); diff --git a/src/web-ui/src/flow_chat/services/openSessionContextPreview.ts b/src/web-ui/src/flow_chat/services/openSessionContextPreview.ts index a339826c19..c71ee0bc92 100644 --- a/src/web-ui/src/flow_chat/services/openSessionContextPreview.ts +++ b/src/web-ui/src/flow_chat/services/openSessionContextPreview.ts @@ -3,6 +3,7 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac import { openAgentCanvasTabForSession, useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores/canvasStore'; import { flowChatStore } from '../store/FlowChatStore'; import { resolveSessionDriverId } from '../session-drivers/resolve'; +import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal'; const PREVIEW_FILE_NAMES: Record = { systemPrompt: 'system-prompt.md', @@ -28,13 +29,21 @@ export async function openSessionContextPreview(params: { if (!workspaceId || params.sessionId !== params.reportSessionId || resolveSessionDriverId(params.sessionId, session) !== 'local') return 'unavailable'; - const report = await sessionAPI.getSessionUsageReport({ - sessionId: params.sessionId, - workspaceId, - includeHiddenSubagents: false, - contextPreviewCategory: params.category, - ...(params.category === 'systemPrompt' ? { includeSystemPromptPreview: true } : {}), - }); + let report; + try { + report = await sessionAPI.getSessionUsageReport({ + sessionId: params.sessionId, + workspaceId, + includeHiddenSubagents: false, + contextPreviewCategory: params.category, + ...(params.category === 'systemPrompt' ? { includeSystemPromptPreview: true } : {}), + }); + } catch (error) { + // A peer host without the report (for example an older CLI host) does not + // offer previews either; that is the calm unavailable state, not a failure. + if (classifyPeerHostRefusal(error)) return surface.isCurrent() ? 'unavailable' : 'stale'; + throw error; + } if (!surface.isCurrent()) return 'stale'; if (report.sessionId !== params.sessionId) return 'unavailable'; const snapshot = report.contextPreview; diff --git a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts index 79e94c2254..e09b55262a 100644 --- a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts +++ b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts @@ -53,7 +53,9 @@ export function beginSessionUsagePanel(params: { window.dispatchEvent(new window.CustomEvent('expand-right-panel')); } - const update = (patch: { report?: SessionUsageReport; markdown?: string; error?: string }): boolean => { + const update = (patch: { + report?: SessionUsageReport; markdown?: string; error?: string; unsupported?: string; + }): boolean => { if (!surface.isCurrent()) return false; const replace = (previous: PanelContent): PanelContent | undefined => ( previous.type === SESSION_USAGE_PANEL_TYPE && previous.data?.requestId === requestId @@ -77,6 +79,8 @@ export function beginSessionUsagePanel(params: { isCurrent: surface.isCurrent, complete: (report: SessionUsageReport, markdown: string) => update({ report, markdown }), fail: (error: string) => update({ error }), + /** The executing host does not offer the report: a calm state, not a failure. */ + unsupported: (message: string) => update({ unsupported: message }), }; } diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index 0de85dcd26..510d123899 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + prewarm: vi.fn(), flowChatState: { activeSessionId: null as string | null, sessions: new Map(), @@ -34,11 +35,12 @@ vi.mock('@/app/stores/sceneStore', () => ({ vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => mocks.flowChatState, + hasUnpromotedSeed: () => false, }, })); vi.mock('./FlowChatManager', () => ({ - flowChatManager: { switchChatSession: mocks.switchChatSession }, + flowChatManager: { switchChatSession: mocks.switchChatSession, prewarmHistoricalSession: mocks.prewarm }, })); vi.mock('./storeSync', () => ({ @@ -227,6 +229,9 @@ describe('openMainSession resource activation', () => { await Promise.all([first, second]); expect(mocks.setActiveWorkspace.mock.calls.map(([id]) => id)).toEqual(['a', 'b']); + // The history read starts with the workspace switch, not after it. + expect(mocks.prewarm).toHaveBeenCalledWith('a'); + expect(mocks.prewarm.mock.invocationCallOrder[0]).toBeLessThan(mocks.setActiveWorkspace.mock.invocationCallOrder[0]); expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['b']); expect(mocks.workspaceState.activeWorkspaceId).toBe('b'); expect(mocks.sceneState.openScene).toHaveBeenCalledTimes(1); diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index e47b522c44..1784af102b 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -37,6 +37,12 @@ export async function activateMainSession(sessionId: string, options?: SessionAc const request = ++activationRequest; const isCurrent = () => scope.isCurrent() && request === activationRequest && (options?.isCurrent?.() ?? true); if (!isCurrent()) return false; + // A session first seen on a background device is promoted (model window, + // state machine) before anything opens it, whichever entry point is used. + if (flowChatStore.hasUnpromotedSeed(sessionId)) { + await flowChatStore.promoteSeededSessions(); + if (!isCurrent()) return false; + } const session = flowChatStore.getState().sessions.get(sessionId); const workspace = session && resolveSessionSceneWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); const workspaceId = options?.workspaceId ?? workspace?.id; @@ -44,6 +50,12 @@ export async function activateMainSession(sessionId: string, options?: SessionAc && workspaceManager.getState().currentWorkspace) { throw new Error(i18nService.t('common:sceneBar.workspaceUnavailable')); } + if (workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId) { + // Read the session's history while its workspace is being activated + // instead of after: the read addresses the session's own workspace, and + // the switch below adopts the read already in flight. + flowChatManager.prewarmHistoricalSession(sessionId); + } if (workspaceId) { // Workspace activation mutates the host as well as the frontend. Serialize // it so a slow A request cannot finish after a newer B request on the host. diff --git a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts index 33b8bf9440..83f097dac5 100644 --- a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts +++ b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts @@ -3,6 +3,7 @@ import { agentAPI, type AgenticEvent, type PermissionRequest } from '@/infrastru import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { getActiveSurfaceId, getActiveSurfaceScope, onSurfaceActivated, surfaceIdForDevice, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import { observeSurfaceEvents } from '@/infrastructure/peer-device/deviceSurfaceRouting'; +import { getDeviceOpenIntent, useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; import { createLogger } from '@/shared/utils/logger'; import { flowChatStore } from '../store/FlowChatStore'; import { sessionActivityStore } from '../store/sessionActivityStore'; @@ -246,8 +247,15 @@ export function installSessionNavStatusService(): () => void { } })); } + let refreshAfterOpen: boolean | null = null; const refreshAll = (force = true) => { if (!rows.size) return; + // A cross-device open owns the link until it settles: the statuses of the + // other rows are refreshed once, right after, instead of competing with it. + if (getDeviceOpenIntent()) { + refreshAfterOpen = (refreshAfterOpen ?? false) || force; + return; + } if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; for (const sessionId of rows.keys()) refresh(sessionId, force); void ensureActivePermissionMailbox(true); @@ -262,6 +270,12 @@ export function installSessionNavStatusService(): () => void { publishOrdering(); refreshAll(); })); + disposers.push(useDeviceOpenIntent.subscribe(state => { + if (state.intent || refreshAfterOpen === null) return; + const force = refreshAfterOpen; + refreshAfterOpen = null; + refreshAll(force); + })); const interval = setInterval(() => refreshAll(false), RECONCILE_INTERVAL_MS); const wake = () => { // A host may have restarted/upgraded while this surface was disconnected. @@ -312,6 +326,10 @@ export const sessionNavStatusService = { }, getSortTimestamp: (session: Session): number => ordering.get(getActiveSurfaceId(), session.sessionId)?.sortTimestamp ?? session.lastFinishedAt ?? session.createdAt, + /** The same order key for a device the window is not rendering. */ + getSurfaceSortTimestamp: (surfaceId: DeviceSurfaceId, session: Session): number => ordering.get(surfaceId, session.sessionId)?.sortTimestamp + ?? session.lastFinishedAt ?? session.createdAt, + isSurfaceRunning: (surfaceId: DeviceSurfaceId, sessionId: string): boolean => ordering.get(surfaceId, sessionId)?.status.kind === 'running', isRunning: (sessionId: string): boolean => ordering.get(getActiveSurfaceId(), sessionId)?.status.kind === 'running', getSnapshot: (sessionId: string): SessionNavStatus => rows.get(sessionId)?.status ?? IDLE, subscribe(sessionId: string, notify: () => void): () => void { diff --git a/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts b/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts index 1e777842f6..8cf34e0bcd 100644 --- a/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts @@ -110,4 +110,13 @@ describe('sessionOpenIntent', () => { expect(shouldShowHistorySessionOpenIntent(metadataOnlySession, { isRunning: true })).toBe(false); expect(shouldShowHistorySessionOpenIntent(renderableSession)).toBe(false); }); + + it('keeps a cross-device open overlay for its longer window, with its title', () => { + vi.useFakeTimers(); + dispatchHistorySessionOpenIntent('remote-1', 'Remote', 20_000); + vi.advanceTimersByTime(10_000); + expect(getHistorySessionOpenTransitionSnapshot()).toMatchObject({ sessionId: 'remote-1', sessionTitle: 'Remote', maxMs: 20_000 }); + vi.advanceTimersByTime(10_000); + expect(getHistorySessionOpenTransitionSnapshot()).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts b/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts index c3c5dceb23..9a5d7ea4b6 100644 --- a/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts +++ b/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts @@ -9,18 +9,22 @@ const RECENT_HISTORY_OPEN_INTENT_MS = 750; const HISTORY_SESSION_OPEN_TRANSITION_MAX_MS = 4_000; let recentHistoryOpenIntent: { sessionId: string; atMs: number } | null = null; -let activeHistorySessionOpenTransition: { sessionId: string; atMs: number } | null = null; +let activeHistorySessionOpenTransition: HistorySessionOpenTransitionSnapshot | null = null; let activeHistorySessionOpenTransitionTimer: ReturnType | null = null; const transitionListeners = new Set<() => void>(); export interface HistorySessionOpenIntentDetail { sessionId: string; sessionTitle?: string; + /** How long the overlay may wait; a cross-device open first renders another device. */ + maxMs?: number; } export interface HistorySessionOpenTransitionSnapshot { sessionId: string; atMs: number; + sessionTitle?: string; + maxMs: number; } const nowMs = (): number => ( @@ -72,14 +76,18 @@ export function shouldShowHistorySessionOpenIntent( return session.historyState === 'ready' && session.contextRestoreState === 'pending'; } -export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle?: string): void { +export function dispatchHistorySessionOpenIntent( + sessionId: string, + sessionTitle?: string, + maxMs = HISTORY_SESSION_OPEN_TRANSITION_MAX_MS, +): void { const atMs = nowMs(); beginHistorySessionDiagnostics(sessionId, 'history_open_intent_dispatched'); recentHistoryOpenIntent = { sessionId, atMs, }; - activeHistorySessionOpenTransition = { sessionId, atMs }; + activeHistorySessionOpenTransition = { sessionId, atMs, sessionTitle, maxMs }; clearHistorySessionOpenTransitionTimer(); activeHistorySessionOpenTransitionTimer = setTimeout(() => { if ( @@ -87,13 +95,13 @@ export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle activeHistorySessionOpenTransition.atMs === atMs ) { recordHistorySessionDiagnosticEvent(sessionId, 'history_open_transition_expired', { - durationMs: HISTORY_SESSION_OPEN_TRANSITION_MAX_MS, + durationMs: maxMs, }); activeHistorySessionOpenTransition = null; activeHistorySessionOpenTransitionTimer = null; notifyHistorySessionOpenTransitionListeners(); } - }, HISTORY_SESSION_OPEN_TRANSITION_MAX_MS); + }, maxMs); notifyHistorySessionOpenTransitionListeners(); if (typeof window === 'undefined') { @@ -102,7 +110,7 @@ export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle window.dispatchEvent(new CustomEvent( HISTORY_SESSION_OPEN_INTENT_EVENT, - { detail: { sessionId, sessionTitle } }, + { detail: { sessionId, sessionTitle, maxMs } }, )); } @@ -137,7 +145,7 @@ export function getHistorySessionOpenTransitionSnapshot(): HistorySessionOpenTra return null; } - if (nowMs() - transition.atMs > HISTORY_SESSION_OPEN_TRANSITION_MAX_MS) { + if (nowMs() - transition.atMs > transition.maxMs) { return null; } diff --git a/src/web-ui/src/flow_chat/services/usageReportService.test.ts b/src/web-ui/src/flow_chat/services/usageReportService.test.ts index baabdb564a..e649aac23e 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.test.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.test.ts @@ -254,6 +254,33 @@ describe('runUsageReportCommand', () => { expect(transcriptOf()).toEqual([]); }); + it('shows an older peer host without the report as a calm unsupported state and no error toast', async () => { + const { notificationService } = await import('@/shared/notification-system'); + vi.mocked(notificationService.error).mockClear(); + sessionApiMocks.getSessionUsageReport.mockRejectedValueOnce(new Error( + "command 'get_session_usage_report' is not supported on CLI peer host: the CLI peer host has no handler for this command", + )); + const { runUsageReportCommand } = await import('./usageReportService'); + + await expect(runUsageReportCommand({ session: createSession(), ...uiParams })) + .rejects.toThrow('not supported on CLI peer host'); + + expect(notificationService.error).not.toHaveBeenCalled(); + expect(panelData()).toMatchObject({ isLoading: false }); + expect(panelData()?.error).toBeUndefined(); + expect(typeof panelData()?.unsupported).toBe('string'); + }); + + it('keeps the error toast for ordinary report failures', async () => { + const { notificationService } = await import('@/shared/notification-system'); + vi.mocked(notificationService.error).mockClear(); + sessionApiMocks.getSessionUsageReport.mockRejectedValueOnce(new Error('report failed')); + const { runUsageReportCommand } = await import('./usageReportService'); + + await expect(runUsageReportCommand({ session: createSession(), ...uiParams })).rejects.toThrow(); + expect(notificationService.error).toHaveBeenCalledTimes(1); + }); + it('does not open at all when there is no workspace to report on', async () => { const { runUsageReportCommand } = await import('./usageReportService'); diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 50cf164ba4..f91f147c4c 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -6,6 +6,7 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac import type { Session } from '../types/flow-chat'; import { requireSessionOwningWorkspaceId } from '../utils/sessionOrdering'; import { i18nService } from '@/infrastructure/i18n'; +import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal'; import { formatCacheHitRate, formatTokenCount, @@ -90,6 +91,14 @@ export async function runUsageReportCommand( return { shown, report }; } catch (error) { if (!surface.isCurrent()) return { shown: false }; + // A peer host that does not offer the report (an older CLI host, or a host + // kind without it) is an expected state, not a failure: say so in place + // and skip the error toast. Callers still see the rejection. + const unsupported = usageReportUnsupportedMessage(error); + if (unsupported) { + panel?.unsupported(unsupported); + throw error; + } panel?.fail(error instanceof Error ? error.message : params.unknownErrorMessage); notificationService.error( error instanceof Error ? error.message : params.unknownErrorMessage, @@ -102,6 +111,18 @@ export async function runUsageReportCommand( } } +/** + * Localized calm copy when the executing peer host does not offer the usage + * report, or `null` for any other error. + */ +export function usageReportUnsupportedMessage(error: unknown): string | null { + const refusal = classifyPeerHostRefusal(error); + if (!refusal) return null; + return i18nService.t(refusal.kind === 'updateRequired' + ? 'flow-chat:usage.context.updateRequired' + : 'flow-chat:usage.context.unsupported'); +} + export function enrichUsageReportModelIdentity( report: SessionUsageReport, session: Session diff --git a/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts new file mode 100644 index 0000000000..b0cbda96fe --- /dev/null +++ b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SESSION_VIEW_KEY, + forgetSurfaceSessionTotals, + knownWorkspaceSessionTotal, + presentedSessionTotal, + rememberWorkspaceSessionTotal, +} from './workspaceSessionTotals'; + +describe('workspaceSessionTotals', () => { + it('keeps the last settled total while a catalog reader restarts', () => { + // Reader restarted: only the 5 seeded rows are countable. + expect(presentedSessionTotal(false, 5, 8)).toBe(8); + // A settled read is authoritative, also when the total shrank. + expect(presentedSessionTotal(true, 6, 8)).toBe(6); + expect(presentedSessionTotal(false, 5, undefined)).toBe(5); + }); + + it('remembers totals per device, workspace and view', () => { + rememberWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY, 8); + rememberWorkspaceSessionTotal('peer-b', 'w', DEFAULT_SESSION_VIEW_KEY, 3); + expect(knownWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY)).toBe(8); + expect(knownWorkspaceSessionTotal('peer-a', 'w', 'filtered')).toBeUndefined(); + forgetSurfaceSessionTotals('peer-a'); + expect(knownWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY)).toBeUndefined(); + expect(knownWorkspaceSessionTotal('peer-b', 'w', DEFAULT_SESSION_VIEW_KEY)).toBe(3); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts new file mode 100644 index 0000000000..8ff0309eb0 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts @@ -0,0 +1,36 @@ +/** + * Last known top-level session total of a workspace view, per device. + * + * Navigation reloads a workspace's catalog whenever its reader restarts (a + * device switch, a refresh after a local change, a new activation epoch). + * While it reloads, only the loaded rows are countable, so the "show more" row + * and its count would vanish and come back. Both projections of a device (the + * background rows and the live list) read and write this one value, so the row + * keeps the last host-reported total until a newer one arrives. + */ + +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** The view every host total without a query describes. */ +export const DEFAULT_SESSION_VIEW_KEY = 'default'; + +const totals = new Map(); +const key = (surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string) => JSON.stringify([surfaceId, workspaceId, viewKey]); + +export function rememberWorkspaceSessionTotal(surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string, total: number): void { + totals.set(key(surfaceId, workspaceId, viewKey), total); +} + +export function knownWorkspaceSessionTotal(surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string): number | undefined { + return totals.get(key(surfaceId, workspaceId, viewKey)); +} + +export function forgetSurfaceSessionTotals(surfaceId: DeviceSurfaceId): void { + const prefix = JSON.stringify([surfaceId]).slice(0, -1) + ','; + for (const entry of Array.from(totals.keys())) if (entry.startsWith(prefix)) totals.delete(entry); +} + +/** The total to present: settled reads are authoritative, restarts keep the last one. */ +export function presentedSessionTotal(settled: boolean, liveTotal: number, remembered: number | undefined): number { + return settled ? liveTotal : Math.max(liveTotal, remembered ?? 0); +} diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts index 77ee8df312..fc854b259a 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts @@ -131,6 +131,40 @@ describe('localSessionDriver conversation creation', () => { expect(poolMocks.read).not.toHaveBeenCalled(); }); + it('reuses the creation-scoped project defaults read instead of reading again', async () => { + const shared = vi.fn(async () => ({ supported: true, defaults: { primary_model: 'shared-model' } })); + const createSession = vi.fn(); + await localSessionDriver.createSession({ flowChatStore: { createSession } } as any, { + ...seed(), config: {}, readProjectDefaults: shared, + }); + expect(shared).toHaveBeenCalledTimes(1); + expect(projectDefaults.read).not.toHaveBeenCalled(); + expect(createSession.mock.calls[0][1].modelName).toBe('shared-model'); + }); + + it('opens an assistant session before its title metadata is synchronized', async () => { + const { initializeSessionTitleMetadata } = await import('../../services/sessionTitleMetadata'); + let finishTitle!: (descriptor: unknown) => void; + vi.mocked(initializeSessionTitleMetadata).mockImplementationOnce(() => new Promise(resolve => { + finishTitle = resolve as (descriptor: unknown) => void; + })); + const descriptor = { text: 'New chat', source: 'i18n' as const, key: 'flow-chat:session.new' }; + let state = { sessions: new Map() }; + const createSession = vi.fn((id: string) => { + state = { sessions: new Map(state.sessions).set(id, { sessionId: id, titleSource: 'i18n' }) }; + }); + const setState = vi.fn((update: (current: typeof state) => typeof state) => { state = update(state); }); + await expect(localSessionDriver.createSession( + { flowChatStore: { createSession, setState } } as any, + { ...seed('Claw'), titleDescriptor: descriptor }, + )).resolves.toBe('host-session'); + expect(createSession).toHaveBeenCalledTimes(1); + expect(createSession.mock.calls[0].at(-1)).toBe(descriptor); + + finishTitle({ ...descriptor, workspaceSessionNumber: 3 }); + await vi.waitFor(() => expect(state.sessions.get('host-session')?.workspaceSessionNumber).toBe(3)); + }); + it('preserves an explicit single-model choice even when a default pool exists', async () => { poolMocks.supported.mockReturnValue(true); const createSession = vi.fn(); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 8e5bba1d69..7714b1589a 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -56,6 +56,31 @@ import { inheritReviewPermissionMode } from '../../services/inheritReviewPermiss const log = createLogger('LocalSessionDriver'); +async function syncCreatedSessionTitle( + context: FlowChatContext, + sessionId: string, + descriptor: SessionCreationSeed['titleDescriptor'], + workspaceId: string, + surfaceScope: SessionCreationSeed['surfaceScope'], +): Promise { + try { + const title = await initializeSessionTitleMetadata(sessionId, descriptor, workspaceId, surfaceScope); + if (title.workspaceSessionNumber === undefined) return; + context.flowChatStore.setState(state => { + const current = state.sessions.get(sessionId); + // A title generated or edited meanwhile owns the display; keep it. + if (!current || current.titleSource !== 'i18n') return state; + return { ...state, sessions: new Map(state.sessions).set(sessionId, { + ...current, workspaceSessionNumber: title.workspaceSessionNumber, + }) }; + }); + } catch (error) { + // A surface change abandons this projection; the session itself was + // created and the next catalog read carries the host-owned number. + log.debug('Skipped created session title synchronization', { sessionId, error }); + } +} + export const localSessionDriver: SessionDriver = { id: 'local', @@ -80,11 +105,17 @@ export const localSessionDriver: SessionDriver = { ?? catalog.recentWorkspaces.find(item => item.id === workspaceId); const projectId = workspace?.worktree && !workspace.worktree.isMain && workspace.workspaceKind !== 'remote' ? workspace.worktree.mainWorkspaceId : workspaceId; - const projectPrimary = projectId ? (await readProjectDefaults(projectId)).defaults.primary_model : undefined; + // Reuse the creation's project defaults read: the mode resolver usually + // issued it already, so this costs no second host round trip. + const readDefaults = seed.readProjectDefaults + ?? (projectId ? () => readProjectDefaults(projectId) : undefined); + const projectPrimary = readDefaults ? (await readDefaults()).defaults.primary_model : undefined; surfaceScope.assertCurrent('resolve project default model'); if (projectPrimary) config = { ...config, modelName: projectPrimary }; } if (!config.modelName && !config.modelPoolId && !acpClientIdFromAgentType(agentType)) { + // Served from the config manager cache when warm; read only when no + // project model applies. const { mode: selection } = await readModelSelections(); surfaceScope.assertCurrent('resolve default model target'); if (selection.kind === 'pool') { @@ -99,13 +130,20 @@ export const localSessionDriver: SessionDriver = { const explicitModelName = config.modelName?.trim() || undefined; - const reasoningPreset = config.modelPoolId ? undefined : config.reasoningPreset - ?? await resolveReasoningPresetForSessionCreation(explicitModelName); - surfaceScope.assertCurrent('resolve session creation reasoning preset'); + const requestedReasoningPreset = config.modelPoolId ? undefined : config.reasoningPreset; + const readReasoningPreset = (): Promise => ( + config.modelPoolId || requestedReasoningPreset != null + ? Promise.resolve(requestedReasoningPreset ?? undefined) + : resolveReasoningPresetForSessionCreation(explicitModelName) + ); if (seed.draftId && agentType !== 'Claw' && !config.executionTargetRequest) { if (!workspaceId) throw new Error('Draft workspace ID is unavailable'); - const maxContextTokens = await getModelMaxTokens(explicitModelName, agentType); + // Both reads only depend on the model already resolved above. + const [reasoningPreset, maxContextTokens] = await Promise.all([ + readReasoningPreset(), + getModelMaxTokens(explicitModelName, agentType), + ]); surfaceScope.assertCurrent('prepare conversation draft'); context.flowChatStore.createSession(seed.draftId, { ...config, workspaceId, workspacePath, projectWorkspacePath, reasoningPreset, @@ -116,6 +154,9 @@ export const localSessionDriver: SessionDriver = { return seed.draftId; } + const reasoningPreset = await readReasoningPreset(); + surfaceScope.assertCurrent('resolve session creation reasoning preset'); + const response = await agentAPI.createSession({ sessionName, agentType, @@ -161,11 +202,11 @@ export const localSessionDriver: SessionDriver = { executionTarget: response.executionTarget, }; - const createdTitleDescriptor = await initializeSessionTitleMetadata( - response.sessionId, titleDescriptor, requireSessionOwningWorkspaceId({ config: resolvedConfig }), - surfaceScope, - ); - + // The session exists on the host already; its default title renders from + // the descriptor at once. Synchronize the host-owned title identity and + // workspace session number off the creation critical path (three metadata + // round trips, each a relay round trip in Peer Device Mode), the same way + // a materialized draft does. context.flowChatStore.createSession( response.sessionId, resolvedConfig, @@ -176,7 +217,11 @@ export const localSessionDriver: SessionDriver = { effectiveWorkspacePath, remoteConnectionId, remoteSshHost, - createdTitleDescriptor, + titleDescriptor, + ); + void syncCreatedSessionTitle( + context, response.sessionId, titleDescriptor, + requireSessionOwningWorkspaceId({ config: resolvedConfig }), surfaceScope, ); return response.sessionId; diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 5756e6eb99..6e3abddc66 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -21,6 +21,7 @@ import type { SessionDriverId } from './resolve'; import type { SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { LineRange } from '@/shared/editor/LineRange'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import type { ProjectDefaultsConfig } from '@/infrastructure/config/types'; export type { SessionDriverId } from './resolve'; @@ -203,6 +204,11 @@ export interface SessionCreationSeed { workspaceId?: string; remoteConnectionId?: string; remoteSshHost?: string; + /** + * Shared read of the owning project's defaults for this creation. The mode + * resolver may already have issued it; reuse it instead of reading again. + */ + readProjectDefaults?: () => Promise<{ supported: boolean; defaults: ProjectDefaultsConfig }>; } /** Cascade facts computed once by the shared caller before removal. */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 21442f1407..912ef8e4ba 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -814,7 +814,7 @@ describe('FlowChatStore metadata persistence callbacks', () => { expect(flowChatStore.getState().sessions.get(session.sessionId)).toMatchObject({ hasUnreadCompletion: undefined, needsUserAttention: 'tool_confirm', }); - expect(persist).toHaveBeenCalledWith(session.sessionId, undefined); + expect(persist).toHaveBeenCalledWith(session.sessionId, undefined, summary.lastTurn); expect(sessionActivityStore.get(session.sessionId)?.summary?.unreadCompletion).toBeUndefined(); sessionActivityStore.applyRead(sessionActivityStore.beginRead(), [summary]); @@ -865,7 +865,7 @@ describe('FlowChatStore metadata persistence callbacks', () => { flowChatStore.clearSessionUnreadCompletion(session.sessionId); expect(persist).toHaveBeenCalledTimes(1); - expect(persist).toHaveBeenCalledWith(session.sessionId, undefined); + expect(persist).toHaveBeenCalledWith(session.sessionId, undefined, undefined); }); it('persists attention clear only when the session state changes', () => { @@ -9292,6 +9292,25 @@ describe('FlowChatStore reconstructable history residency', () => { expect(flowChatStore.getSessionHistoryViewState(session.sessionId)).toBeUndefined(); }); + it('keeps the latest turns of a long session when first evicted, so it reopens without a read', () => { + const turns = (flowChatStore as any).convertToDialogTurns(Array.from({ length: 10 }, (_, index) => createPersistedTurn(index))); + const session = createSession({ sessionId: 'history-1', dialogTurns: turns, + historyState: 'ready', turnCatalog: createTurnCatalog(10), totalTurnCount: 10 }); + flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]), activeSessionId: null })); + (flowChatStore as any).seedSessionHistoryLoadedRanges(session.sessionId); + (flowChatStore as any).updateHistoryResidency(); + withHistoryBudgetPressure(() => resourceBudget.trim()); + const warm = flowChatStore.getState().sessions.get(session.sessionId)!; + expect(warm.dialogTurns.map(turn => turn.id)).toEqual(turns.slice(-3).map((turn: DialogTurn) => turn.id)); + expect(warm.historyState).toBe('ready'); + expect(flowChatStore.getSessionHistoryViewState(session.sessionId)?.loadedRanges[0]) + .toMatchObject({ startOrdinal: 7, endOrdinalExclusive: 10 }); + // Under continued pressure the tail goes too. + (flowChatStore as any).updateHistoryResidency(); + withHistoryBudgetPressure(() => resourceBudget.trim()); + expect(flowChatStore.getState().sessions.get(session.sessionId)?.historyState).toBe('metadata-only'); + }); + it('protects a visible child and never evicts a newly edited or streaming projection', () => { const turns = (flowChatStore as any).convertToDialogTurns([createPersistedTurn(0)]); const session = createSession({ sessionId: 'history-1', dialogTurns: turns, historyState: 'ready' }); @@ -9308,3 +9327,116 @@ describe('FlowChatStore reconstructable history residency', () => { expect(flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns).toBe(liveTurns); }); }); + +describe('background device session seeding', () => { + const metadata = (sessionId: string, extra: Record = {}) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt: 10, lastActiveAt: 20, + turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], workspaceId: 'peer-ws', ...extra, + }) as never; + const page = (sessions: never[]) => [{ workspaceId: 'peer-ws', workspacePath: '/srv/app', sessions }]; + + afterEach(() => { + activateSurface(LOCAL_SURFACE_ID); + flowChatStore.discardSurfaceState('peer-seed'); + }); + + it('projects a background device into its own container, never the rendered one', () => { + const listener = vi.fn(); + const unsubscribe = flowChatStore.subscribeSurfaceSessions(listener); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1'), metadata('archived', { status: 'archived' })])); + flowChatStore.seedSurfaceSessions(LOCAL_SURFACE_ID, page([metadata('local-1')])); + unsubscribe(); + const seeded = flowChatStore.getSurfaceSessions('peer-seed'); + expect([...seeded.keys()]).toEqual(['s-1']); + expect(seeded.get('s-1')).toMatchObject({ workspaceId: 'peer-ws', historyState: 'metadata-only', dialogTurns: [] }); + expect(flowChatStore.getState().sessions.has('local-1')).toBe(false); + expect(listener).toHaveBeenCalledWith('peer-seed'); + // An identical page does not notify again. + listener.mockClear(); + flowChatStore.subscribeSurfaceSessions(listener); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + expect(listener).not.toHaveBeenCalled(); + }); + + it('finds the seeds after a switch and rebuilds them through the metadata path', async () => { + workspaceFixtures.set('peer-ws', { id: 'peer-ws', rootPath: '/srv/app', workspaceKind: 'normal' }); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + expect(flowChatStore.getState().sessions.get('s-1')?.workspaceId).toBe('peer-ws'); + await flowChatStore.promoteSeededSessions(); + expect(stateMachineManagerMock.getOrCreate).toHaveBeenCalledWith('s-1'); + expect(flowChatStore.getState().sessions.get('s-1')).toMatchObject({ workspaceId: 'peer-ws', historyState: 'metadata-only' }); + workspaceFixtures.delete('peer-ws'); + }); + + it('never replaces a seeded session once it has history', async () => { + workspaceFixtures.set('peer-ws', { id: 'peer-ws', rootPath: '/srv/app', workspaceKind: 'normal' }); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.set('s-1', { ...sessions.get('s-1')!, historyState: 'ready', dialogTurns: [{ id: 'turn' } as never] }); + return { ...state, sessions }; + }); + await flowChatStore.promoteSeededSessions(); + expect(flowChatStore.getState().sessions.get('s-1')?.dialogTurns).toHaveLength(1); + workspaceFixtures.delete('peer-ws'); + }); + + it('refreshes navigation facts of a loaded background session without dropping its state', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.set('s-1', { ...sessions.get('s-1')!, historyState: 'ready', dialogTurns: [{ id: 'turn' } as never] }); + return { ...state, sessions }; + }); + activateSurface(LOCAL_SURFACE_ID); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1', { sessionName: 'Renamed', lastActiveAt: 99 })])); + const session = flowChatStore.getSurfaceSessions('peer-seed').get('s-1'); + expect(session).toMatchObject({ title: 'Renamed', lastActiveAt: 99 }); + expect(session?.dialogTurns).toHaveLength(1); + }); + + it('drops sessions its host deleted or archived, never the rendered device or drafts', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1'), metadata('s-2')])); + flowChatStore.dropSurfaceSessions('peer-seed', ['s-2', 'missing']); + expect([...flowChatStore.getSurfaceSessions('peer-seed').keys()]).toEqual(['s-1']); + flowChatStore.seedSurfaceSessions(LOCAL_SURFACE_ID, page([metadata('local-1')])); + flowChatStore.dropSurfaceSessions(LOCAL_SURFACE_ID, ['s-1']); + expect([...flowChatStore.getSurfaceSessions('peer-seed').keys()]).toEqual(['s-1']); + }); + + it('adds only missing sessions from an older page', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1', { sessionName: 'Current' })])); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1', { sessionName: 'Older' }), metadata('s-2')]), { onlyMissing: true }); + const sessions = flowChatStore.getSurfaceSessions('peer-seed'); + expect([...sessions.keys()]).toEqual(['s-1', 's-2']); + expect(sessions.get('s-1')?.title).toBe('Current'); + }); + + it('drops only seeds when the account changes', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + flowChatStore.discardSeededSessions('peer-seed'); + expect(flowChatStore.getSurfaceSessions('peer-seed').size).toBe(0); + }); +}); + +describe('read receipts of unloaded sessions', () => { + it('persists the host record of the result even when no turn was loaded', async () => { + workspaceFixtures.set('rr-ws', { id: 'rr-ws', rootPath: '/srv/rr', workspaceKind: 'normal' }); + const target = { turnId: 'turn-9', turnIndex: 9, status: 'cancelled', executionGeneration: 2, recoveryPending: true }; + await flowChatStore.adoptHostSessionMetadata('rr-ws', [{ + sessionId: 'rr-1', sessionName: 'rr', agentType: 'agentic', modelName: 'm', createdAt: 1, lastActiveAt: 2, + turnCount: 9, messageCount: 9, toolCallCount: 0, status: 'active', tags: [], workspaceId: 'rr-ws', + unreadCompletion: 'interrupted', lastTurn: { turnId: 'turn-10', turnIndex: 10, status: 'completed' }, + customMetadata: { unreadCompletionTarget: target }, + } as never]); + const persisted: unknown[] = []; + flowChatStore.registerPersistUnreadCompletionCallback((...args) => { persisted.push(args); }); + flowChatStore.clearSessionUnreadCompletion('rr-1'); + expect(persisted).toEqual([['rr-1', undefined, target]]); + expect(flowChatStore.getState().sessions.get('rr-1')?.unreadCompletionReceipt).toBeUndefined(); + workspaceFixtures.delete('rr-ws'); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e8a36b44fb..c6804fdf4b 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -72,6 +72,8 @@ import type { SessionContextUsage, SessionActivitySummary, SessionKind, + SessionLastTurn, + SessionMetadata, SessionTurnCatalog, } from '@/shared/types/session-history'; import { @@ -348,6 +350,8 @@ function sameDispatchTargetIdentity( const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; const HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT = 3; +/** Turns a session keeps in memory when the history budget evicts it. */ +const RESIDENT_TAIL_TURN_COUNT = HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT; const HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS = 1500; const HISTORICAL_SESSION_PREVIOUS_WINDOW_TURN_COUNT = 12; const PEER_SESSION_REFRESH_TAIL_TURN_COUNT = 3; @@ -2111,6 +2115,29 @@ interface SurfaceStateContainer { /** Sessions rebuilding from a restarted host stream until its replay is ready. */ readonly relayResyncingSessions: Set; readonly relaySessionHistory: Map; + /** + * Sessions projected from a background device's catalog while this surface + * was not rendered. They carry no history or state machine yet; the first + * metadata pass after activation rebuilds them through the normal path. + */ + readonly seededSessions: Map; +} + +interface SeededSessionRecord { + metadata: SessionMetadata; + workspaceId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +/** One workspace page of a background device's catalog. */ +export interface BackgroundSessionPage { + workspaceId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; + sessions: readonly SessionMetadata[]; } function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateContainer { @@ -2134,6 +2161,7 @@ function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateCo relayPendingRecordTurns: new Map(), relayResyncingSessions: new Set(), relaySessionHistory: new Map(), + seededSessions: new Map(), }; } @@ -2141,6 +2169,98 @@ const isTerminalRelayTurnStatus = (status: DialogTurn['status']): boolean => ( status === 'completed' || status === 'cancelled' || status === 'error' ); +/** + * The FlowChat projection of one persisted session record, before any history + * is loaded. Shared by metadata pages and background-device seeding so a + * session looks the same whichever path put it in a surface container. + */ +function sessionFromPersistedMetadata(metadata: any, context: { + workspaceId?: string; + projectWorkspaceId?: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; + maxContextTokens: number; +}): Session { + const { workspaceId, projectWorkspaceId, workspacePath, maxContextTokens } = context; + const relationship = deriveSessionRelationshipFromMetadata(metadata); + const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); + const titleState = deriveSessionTitleStateFromMetadata(metadata); + const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; + const remoteScope = persistedSessionRemoteScope(metadata, context.remoteConnectionId, context.remoteSshHost); + const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); + const restoredAgentType = resolveRestoredAgentType(metadata.agentType); + const restoredCurrentTokenUsage = isAcpAgentType(restoredAgentType) + ? undefined + : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); + + return { + sessionId: metadata.sessionId, + workspaceId, projectWorkspaceId, + title: titleState.title, + titleSource: titleState.titleSource, + titleI18nKey: titleState.titleI18nKey, + titleI18nParams: titleState.titleI18nParams, + workspaceSessionNumber: titleState.workspaceSessionNumber, + titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', + dialogTurns: [], + status: 'idle', + persistedStatus: metadata.status, + persistedTurnCount: metadata.turnCount, + importedSource: metadata.customMetadata?.importedFrom, + externalHistory: externalHistoryFromMetadata(metadata.customMetadata), + externalBranchOf: metadata.customMetadata?.externalBranchOf, + config: { + agentType: restoredAgentType, + modelName: metadata.modelName, + workspaceId, projectWorkspaceId, + workspacePath: metadata.workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + executionTarget: metadata.executionTarget, + }, + createdAt: metadata.createdAt, + lastActiveAt: metadata.lastActiveAt, + lastFinishedAt, + error: null, + isHistorical: true, + historyState: 'metadata-only', + todos: (metadata as any).todos || [], + maxContextTokens, + currentTokenUsage: restoredCurrentTokenUsage, + mode: restoredAgentType, + lastUserDialogMode: metadata.lastUserDialogAgentType, + lastSubmittedMode: metadata.lastSubmittedAgentType, + workspacePath: (metadata as any).workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + remoteConnectionId: remoteScope.remoteConnectionId, + remoteSshHost: remoteScope.remoteSshHost, + workspaceHostname: metadata.workspaceHostname, + parentSessionId: relationship.parentSessionId, + sessionKind: relationship.sessionKind, + parentToolCallId: relationship.parentToolCallId, + subagentType: relationship.subagentType, + continuationPolicy: relationship.continuationPolicy, + btwThreads: [], + btwOrigin: relationship.btwOrigin, + hasUnreadCompletion: metadata.unreadCompletion, + unreadCompletionReceipt: persistedUnreadReceipt(metadata), + needsUserAttention: metadata.needsUserAttention, + deepReviewRunManifest: metadata.deepReviewRunManifest, + reviewTargetEvidence: metadata.reviewTargetEvidence, + isTransient: false, + }; +} + +const EMPTY_SESSION_MAP: ReadonlyMap = new Map(); + +/** The host's record of an unread result, as its read receipt must name it. */ +function persistedUnreadReceipt(metadata: any): SessionLastTurn | undefined { + if (!metadata?.unreadCompletion) return undefined; + return metadata.customMetadata?.unreadCompletionTarget ?? metadata.lastTurn ?? undefined; +} +/** Placeholder until promotion reads the device's model configuration. */ +const DEFAULT_SEEDED_CONTEXT_TOKENS = 128128; + export class FlowChatStore { private static instance: FlowChatStore; private surfaceContainers = new Map(); @@ -2174,7 +2294,7 @@ export class FlowChatStore { /** Requested intervals remain protected until every deduplicated caller processes the response. */ private sessionTurnWindowProtections = new Map(); private unsupportedRestoreCommands = new Set(); - private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; + private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined, receipt?: SessionLastTurn) => void; private constructor() { this.clearOldStorage(); @@ -2359,6 +2479,36 @@ export class FlowChatStore { this.historyResidency.delete(id); const current = surface.state.sessions.get(sessionId); if (!current) return; + // First eviction keeps the session's latest turns: reopening it + // then shows them at once, exactly as a fresh tail restore would, + // and older turns page in through its catalog. Only a session + // already down to that tail (or without a catalog to page from) + // drops to metadata. + const view = surface.sessionHistoryViews.get(sessionId); + const total = projectedSessionTurnCount(current); + const tail = current.dialogTurns.filter(turn => !isProvisionalUsageReportTurn(turn)) + .slice(-RESIDENT_TAIL_TURN_COUNT); + const hasMoreThanTail = current.dialogTurns.length > tail.length + || (view?.loadedRanges.some(range => range.turns.length > 0 && range.startOrdinal < total - tail.length) ?? false); + const catalog = view?.catalog ?? current.turnCatalog ?? null; + if (view && catalog && tail.length > 0 && total >= tail.length && hasMoreThanTail) { + surface.sessionHistoryViews.set(sessionId, { + catalog, + loadedRanges: [{ + startOrdinal: total - tail.length, endOrdinalExclusive: total, turns: tail, + lastAccessedAt: Date.now(), source: 'initial-tail', + }], + activeRange: null, + pendingTargetOrdinal: null, + navigationGeneration: view.navigationGeneration, + }); + surface.sessionHistoryTurnAccessTimes.delete(sessionId); + const sessions = new Map(surface.state.sessions); + sessions.set(sessionId, { ...current, dialogTurns: tail, isPartial: true, loadedTurnCount: tail.length }); + surface.state = { ...surface.state, sessions }; + if (surface.surfaceId === getActiveSurfaceId()) this.notifyListeners(); + return; + } surface.sessionHistoryViews.delete(sessionId); surface.sessionHistoryTurnAccessTimes.delete(sessionId); const sessions = new Map(surface.state.sessions); @@ -4524,7 +4674,7 @@ export class FlowChatStore { * Called by FlowChatManager during initialization. */ public registerPersistUnreadCompletionCallback( - callback: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void + callback: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined, receipt?: SessionLastTurn) => void ): void { this.onPersistUnreadCompletion = callback; } @@ -7270,15 +7420,20 @@ export class FlowChatStore { // Bind it to the host summary so a later status refresh cannot restore it. const summary = sessionActivityStore.get(sessionId)?.summary; const acknowledgedSummaryTurn = summary?.unreadTurn ?? summary?.lastTurn; + // The receipt names the host's record of the result; capture it before the + // unread fields are cleared, so the persisted acknowledgement can match. + let receipt: SessionLastTurn | undefined; this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session || !session.hasUnreadCompletion) return prev; + receipt = acknowledgedSummaryTurn ?? session.unreadCompletionReceipt; const updatedSession: Session = { ...session, hasUnreadCompletion: undefined, unreadCompletionTurnId: undefined, unreadCompletionGeneration: undefined, + unreadCompletionReceipt: undefined, }; const newSessions = new Map(prev.sessions); @@ -7293,7 +7448,7 @@ export class FlowChatStore { acknowledgedSummaryTurn.executionGeneration, acknowledgedSummaryTurn.recoveryPending); else if (turn) sessionActivityStore.acknowledge(sessionId, turn.id, turn.recovery?.executionGeneration ?? turn.recoveryEpoch, isTurnAwaitingRecovery(turn)); - this.onPersistUnreadCompletion?.(sessionId, undefined); + this.onPersistUnreadCompletion?.(sessionId, undefined, receipt); } } @@ -7325,6 +7480,7 @@ export class FlowChatStore { sessions.set(summary.sessionId, { ...session, hasUnreadCompletion: summary.unreadCompletion, unreadCompletionTurnId: turnId, unreadCompletionGeneration: generation, + unreadCompletionReceipt: summary.unreadCompletion ? unreadTurn : undefined, }); return { ...prev, sessions }; }); @@ -7751,7 +7907,8 @@ export class FlowChatStore { try { logPersistedDispatchMetadataOverlap(metadata, 'metadata-page'); scope.assertCurrent('processPersistedSessionMetadata'); - const existingSession = this.state.sessions.get(metadata.sessionId); + const seededSession = this.isRebuildableSeed(metadata.sessionId); + const existingSession = seededSession ? undefined : this.state.sessions.get(metadata.sessionId); if ((existingSession?.workspaceId || existingSession?.config.workspaceId) && !(existingSession?.sessionKind === 'subagent' && existingSession.continuationPolicy === undefined) && !existingSession?.externalHistory && !existingSession?.externalBranchOf @@ -7793,19 +7950,17 @@ export class FlowChatStore { workspacePath: metadata.projectWorkspacePath || workspacePath, remoteConnectionId, remoteSshHost, }, legacyRecords)?.id; const relationship = deriveSessionRelationshipFromMetadata(metadata); - const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); const titleState = deriveSessionTitleStateFromMetadata(metadata); - const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; const remoteScope = persistedSessionRemoteScope( metadata, remoteConnectionId, remoteSshHost, ); - const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); this.setState(prev => { if (!scope.isCurrent()) return prev; - const existing = prev.sessions.get(metadata.sessionId); + const existing = seededSession ? undefined : prev.sessions.get(metadata.sessionId); + this.activeSurface.seededSessions.delete(metadata.sessionId); if (existing) { const continuationPolicy = existing.continuationPolicy ?? relationship.continuationPolicy; const importedSource = metadata.customMetadata?.importedFrom ?? existing.importedSource; @@ -7836,65 +7991,9 @@ export class FlowChatStore { return { ...prev, sessions }; } - const restoredAgentType = resolveRestoredAgentType(metadata.agentType); - const restoredCurrentTokenUsage = isAcpAgentType(restoredAgentType) - ? undefined - : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); - - const session: Session = { - sessionId: metadata.sessionId, - workspaceId, projectWorkspaceId, - title: titleState.title, - titleSource: titleState.titleSource, - titleI18nKey: titleState.titleI18nKey, - titleI18nParams: titleState.titleI18nParams, - workspaceSessionNumber: titleState.workspaceSessionNumber, - titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', - dialogTurns: [], - status: 'idle', - persistedStatus: metadata.status, - persistedTurnCount: metadata.turnCount, - importedSource: metadata.customMetadata?.importedFrom, - externalHistory: externalHistoryFromMetadata(metadata.customMetadata), - externalBranchOf: metadata.customMetadata?.externalBranchOf, - config: { - agentType: restoredAgentType, - modelName: metadata.modelName, - workspaceId, projectWorkspaceId, - workspacePath: metadata.workspacePath || workspacePath, - projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, - executionTarget: metadata.executionTarget, - }, - createdAt: metadata.createdAt, - lastActiveAt: metadata.lastActiveAt, - lastFinishedAt, - error: null, - isHistorical: true, - historyState: 'metadata-only', - todos: (metadata as any).todos || [], - maxContextTokens, - currentTokenUsage: restoredCurrentTokenUsage, - mode: restoredAgentType, - lastUserDialogMode: metadata.lastUserDialogAgentType, - lastSubmittedMode: metadata.lastSubmittedAgentType, - workspacePath: (metadata as any).workspacePath || workspacePath, - projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, - remoteConnectionId: remoteScope.remoteConnectionId, - remoteSshHost: remoteScope.remoteSshHost, - workspaceHostname: metadata.workspaceHostname, - parentSessionId: relationship.parentSessionId, - sessionKind: relationship.sessionKind, - parentToolCallId: relationship.parentToolCallId, - subagentType: relationship.subagentType, - continuationPolicy: relationship.continuationPolicy, - btwThreads: [], - btwOrigin: relationship.btwOrigin, - hasUnreadCompletion: metadata.unreadCompletion, - needsUserAttention: metadata.needsUserAttention, - deepReviewRunManifest: metadata.deepReviewRunManifest, - reviewTargetEvidence: metadata.reviewTargetEvidence, - isTransient: false, - }; + const session = sessionFromPersistedMetadata(metadata, { + workspaceId, projectWorkspaceId, workspacePath, remoteConnectionId, remoteSshHost, maxContextTokens, + }); const newSessions = new Map(prev.sessions); newSessions.set(metadata.sessionId, session); @@ -7926,6 +8025,187 @@ export class FlowChatStore { } } + /** Whether the rendered surface still holds an unpromoted background seed. */ + public hasUnpromotedSeed(sessionId: string): boolean { + return this.isRebuildableSeed(sessionId); + } + + /** A seed may be replaced only until the session has history or runtime state. */ + private isRebuildableSeed(sessionId: string): boolean { + const container = this.activeSurface; + if (!container.seededSessions.has(sessionId)) return false; + const session = container.state.sessions.get(sessionId); + if (session && session.dialogTurns.length === 0 && session.historyState === 'metadata-only') return true; + container.seededSessions.delete(sessionId); + return false; + } + + private surfaceSessionListeners = new Set<(surfaceId: DeviceSurfaceId) => void>(); + + /** Sessions of any surface's container, for background-device navigation. */ + public getSurfaceSessions(surfaceId: DeviceSurfaceId): ReadonlyMap { + return this.surfaceContainers.get(surfaceId)?.state.sessions ?? EMPTY_SESSION_MAP; + } + + /** Fires when a surface's sessions change: background seeding and active-state updates. */ + public subscribeSurfaceSessions(listener: (surfaceId: DeviceSurfaceId) => void): () => void { + this.surfaceSessionListeners.add(listener); + return () => { this.surfaceSessionListeners.delete(listener); }; + } + + private notifySurfaceSessions(surfaceId: DeviceSurfaceId): void { + for (const listener of Array.from(this.surfaceSessionListeners)) { + try { + listener(surfaceId); + } catch (error) { + log.warn('Surface session listener threw', { error }); + } + } + } + + /** + * Project a background device's catalog pages into that device's container, + * using the same Session projection as a metadata page. Navigation then + * draws the device the same way before and after it is rendered, and a + * switch finds its sessions already present. The rendered surface is never + * seeded: its live reads own it. + * + * `onlyMissing` adds sessions the container lacks and leaves every other + * one as it is: for pages older than what the container already holds. + */ + public seedSurfaceSessions( + surfaceId: DeviceSurfaceId, + pages: readonly BackgroundSessionPage[], + options: { onlyMissing?: boolean } = {}, + ): void { + if (surfaceId === getActiveSurfaceId()) return; + let container = this.surfaceContainers.get(surfaceId); + if (!container) { + container = createSurfaceStateContainer(surfaceId); + this.surfaceContainers.set(surfaceId, container); + } + const sessions = new Map(container.state.sessions); + let changed = false; + for (const page of pages) { + for (const metadata of page.sessions) { + if (metadata.status === 'archived') continue; + const existing = sessions.get(metadata.sessionId); + if (existing && options.onlyMissing) continue; + let seeded = container.seededSessions.has(metadata.sessionId); + if (seeded && existing && (existing.dialogTurns.length > 0 || existing.historyState !== 'metadata-only')) { + // Opened since it was seeded: it is a loaded session now. + container.seededSessions.delete(metadata.sessionId); + seeded = false; + } + if (existing && !seeded) { + // A session this window loaded keeps its state; only the catalog + // facts navigation shows are refreshed. + const titleState = deriveSessionTitleStateFromMetadata(metadata); + const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); + if (existing.title === titleState.title && existing.lastActiveAt === metadata.lastActiveAt + && existing.lastFinishedAt === lastFinishedAt && existing.persistedStatus === metadata.status + && existing.hasUnreadCompletion === metadata.unreadCompletion + && existing.needsUserAttention === metadata.needsUserAttention) continue; + sessions.set(metadata.sessionId, { + ...existing, title: titleState.title, titleSource: titleState.titleSource, + titleI18nKey: titleState.titleI18nKey, titleI18nParams: titleState.titleI18nParams, + lastActiveAt: metadata.lastActiveAt, lastFinishedAt, persistedStatus: metadata.status, + hasUnreadCompletion: metadata.unreadCompletion, needsUserAttention: metadata.needsUserAttention, + unreadCompletionReceipt: persistedUnreadReceipt(metadata), + }); + changed = true; + continue; + } + const record: SeededSessionRecord = { + metadata, workspaceId: metadata.workspaceId ?? page.workspaceId, workspacePath: page.workspacePath, + remoteConnectionId: page.remoteConnectionId, remoteSshHost: page.remoteSshHost, + }; + const previous = container.seededSessions.get(metadata.sessionId); + if (previous && JSON.stringify(previous.metadata) === JSON.stringify(metadata)) continue; + container.seededSessions.set(metadata.sessionId, record); + sessions.set(metadata.sessionId, sessionFromPersistedMetadata(metadata, { + workspaceId: record.workspaceId, + projectWorkspaceId: metadata.projectWorkspaceId ?? record.workspaceId, + workspacePath: record.workspacePath, + remoteConnectionId: record.remoteConnectionId, + remoteSshHost: record.remoteSshHost, + maxContextTokens: DEFAULT_SEEDED_CONTEXT_TOKENS, + })); + changed = true; + } + } + if (!changed) return; + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + + /** + * Rebuild the rendered surface's seeded sessions through the normal + * metadata path (model context window, state machines) before they are + * opened. Untouched seeds only; a session that already has history stays. + */ + public async promoteSeededSessions(): Promise { + const container = this.activeSurface; + const byWorkspace = new Map(); + for (const [sessionId, record] of container.seededSessions) { + if (!this.isRebuildableSeed(sessionId)) continue; + const group = byWorkspace.get(record.workspaceId) ?? []; + group.push(record); + byWorkspace.set(record.workspaceId, group); + } + for (const records of byWorkspace.values()) { + const [first] = records; + await this.processPersistedSessionMetadataList( + records.map(record => record.metadata), first.workspacePath, first.remoteConnectionId, first.remoteSshHost, + ); + } + } + + /** + * Remove sessions a background device's host no longer lists (deleted or + * archived there). Never touches the rendered surface or a draft. + */ + public dropSurfaceSessions(surfaceId: DeviceSurfaceId, sessionIds: readonly string[]): void { + if (surfaceId === getActiveSurfaceId() || sessionIds.length === 0) return; + const container = this.surfaceContainers.get(surfaceId); + if (!container) return; + const sessions = new Map(container.state.sessions); + let changed = false; + for (const sessionId of sessionIds) { + const session = sessions.get(sessionId); + if (!session || session.isTransient || isUnmaterializedSessionDraft(session)) continue; + sessions.delete(sessionId); + container.seededSessions.delete(sessionId); + changed = true; + } + if (!changed) return; + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + + /** Drop seeds of a surface (account change); loaded sessions are untouched. */ + public discardSeededSessions(surfaceId: DeviceSurfaceId): void { + const container = this.surfaceContainers.get(surfaceId); + if (!container || container.seededSessions.size === 0 || surfaceId === getActiveSurfaceId()) return; + const sessions = new Map(container.state.sessions); + for (const sessionId of container.seededSessions.keys()) sessions.delete(sessionId); + container.seededSessions.clear(); + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + + /** + * Adopt metadata the rendered device's host already returned (for example a + * device catalog page read just before this device was rendered), so a + * cross-device open needs no second round trip. Records are host-authored; + * they go through the same projection as a metadata page. + */ + public async adoptHostSessionMetadata(workspaceId: string, sessions: readonly SessionMetadata[]): Promise { + if (sessions.length === 0) return; + const { workspacePath, remoteConnectionId, remoteSshHost } = this.workspaceForId(workspaceId); + await this.processPersistedSessionMetadataList([...sessions], workspacePath, remoteConnectionId, remoteSshHost); + } + /** * Opt-in archived projection for navigation views. Normal session hydration * keeps archived records out of the working set until this is requested. @@ -8336,6 +8616,7 @@ export class FlowChatStore { btwThreads: [], btwOrigin: relationship.btwOrigin, hasUnreadCompletion: metadata.unreadCompletion, + unreadCompletionReceipt: persistedUnreadReceipt(metadata), needsUserAttention: metadata.needsUserAttention, deepReviewRunManifest: metadata.deepReviewRunManifest, reviewTargetEvidence: metadata.reviewTargetEvidence, diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 43cbd49812..f386edb9e6 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -398,11 +398,11 @@ describe('FileOperationToolCard', () => { const { useContentResourceStore } = await vi.importActual< typeof import('../../app/workbench/contentResourceStore') >('../../app/workbench/contentResourceStore'); - const { useSceneStore } = await vi.importActual( + const { useSceneStore, resetSceneStoreForTest } = await vi.importActual( '../../app/stores/sceneStore', ); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); mocks.createDiffEditorTab.mockImplementation(tabUtils.createDiffEditorTab); const config = { toolName: 'Edit', displayName: 'Edit', icon: 'EDIT', diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 90f733c580..ca706b3f26 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -1,3 +1,4 @@ +import type { SessionLastTurn } from '@/shared/types/session-history'; /** * Flow Chat type definitions * Supports mixed streaming output. @@ -602,6 +603,13 @@ export interface Session { /** Result identity from a lightweight summary that may precede transcript hydration. */ unreadCompletionTurnId?: string; unreadCompletionGeneration?: number; + /** + * The host's own record of the unread result (its `unreadCompletionTarget` + * or last turn). A read receipt must name this exact result or the host + * keeps the session unread, so it survives until the receipt is sent even + * when the transcript was never loaded. + */ + unreadCompletionReceipt?: SessionLastTurn; /** * Set when a session requires user attention while not the active session. diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index 73c4000f8b..d1151f5e53 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -1,10 +1,20 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { flowChatStore } from '../store/FlowChatStore'; import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; import { ProcessingPhase, SessionExecutionEvent, SessionExecutionState } from '../state-machine/types'; import type { DialogTurn, Session } from '../types/flow-chat'; import { buildAgentCompanionActivity } from './agentCompanionActivity'; +const background = vi.hoisted(() => ({ rows: [] as unknown[] })); +vi.mock('../services/crossDeviceSessions', () => ({ + listBackgroundDeviceSessions: () => background.rows, + subscribeCrossDeviceSessions: () => () => undefined, +})); +vi.mock('@/infrastructure/account/deviceDirectory', () => ({ + resolveDeviceName: (id: string) => (id === 'peer-a' ? 'Studio Mac' : id), + useDeviceDirectory: { getState: () => ({ localId: null }) }, +})); + function hasLoneSurrogate(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); @@ -384,4 +394,33 @@ describe('buildAgentCompanionActivity', () => { expect(activity.tasks).toHaveLength(1); expect(activity.tasks[0].state).not.toBe('attention'); }); + + it('adds other devices sessions that need attention, run, or finished unread', () => { + const row = (sessionId: string, activity: Record | undefined) => ({ + surfaceId: 'peer-a', + workspaceId: 'ws-1', + session: { sessionId, sessionName: `Remote ${sessionId}`, turnCount: 1, createdAt: 1, lastActiveAt: 2 }, + activity: activity && { sessionId, execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, ...activity }, + }); + background.rows = [ + row('r-approval', { execution: 'running', pendingApprovals: 1 }), + row('r-running', { execution: 'running' }), + row('r-done', { unreadCompletion: 'completed' }), + row('r-idle', {}), + row('r-unknown', undefined), + ]; + try { + const activity = buildAgentCompanionActivity(); + expect(activity.tasks.map(task => [task.sessionId, task.state])).toEqual([ + ['r-approval', 'attention'], + ['r-running', 'running'], + ['r-done', 'completed'], + ]); + expect(activity.tasks.every(task => task.surfaceId === 'peer-a' && task.workspaceId === 'ws-1' + && task.deviceName === 'Studio Mac' && task.canReply === false)).toBe(true); + expect(activity.mood).toBe('waiting'); + } finally { + background.rows = []; + } + }); }); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 3f0634c340..174614764c 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -10,6 +10,15 @@ import { TRANSIENT_TURN_STATUSES, } from './askUserQuestionState'; import { effectiveToolInvocation } from './toolInvocationIdentity'; +import { resolvePersistedSessionTitle } from './sessionTitle'; +import { i18nService } from '@/infrastructure/i18n'; +import { resolveDeviceName, useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; +import { isLocalSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { + listBackgroundDeviceSessions, + subscribeCrossDeviceSessions, + type CrossDeviceSession, +} from '../services/crossDeviceSessions'; export type AgentCompanionTaskState = | 'running' @@ -35,6 +44,13 @@ export interface AgentCompanionTaskStatus { * parent turn context that only the main window composer can supply. */ canReply?: boolean; + /** + * Set for a session on a device this window is not rendering. Opening it + * renders that device first; replying from the pet is not offered there. + */ + surfaceId?: string; + workspaceId?: string; + deviceName?: string; } export interface AgentCompanionActivityPayload { @@ -360,6 +376,48 @@ function isIndependentCompanionSession(session: Session): boolean { return !resolveSessionRelationship(session).isSubagent; } +function deviceLabel(surfaceId: string): string { + if (!isLocalSurface(surfaceId)) return resolveDeviceName(surfaceId); + const localId = useDeviceDirectory.getState().localId; + return localId ? resolveDeviceName(localId) : i18nService.t('common:nav.devices.thisDevice'); +} + +/** A session on a background device, known only through its host summary. */ +function backgroundDeviceTask(row: CrossDeviceSession): AgentCompanionTaskStatus | null { + const activity = row.activity; + if (!activity) return null; + const at = row.session.lastActiveAt || row.session.createdAt; + const base = { + sessionId: row.session.sessionId, + title: toWellFormedText(resolvePersistedSessionTitle(row.session, key => i18nService.t(key))), + startedAt: at, + updatedAt: at, + canReply: false, + surfaceId: row.surfaceId, + workspaceId: row.workspaceId, + deviceName: toWellFormedText(deviceLabel(row.surfaceId)), + }; + if (activity.pendingApprovals > 0) { + return { ...base, mood: 'waiting', state: 'attention', labelKey: 'agentCompanion.activity.waitingApproval', defaultLabel: 'Waiting for approval' }; + } + if (activity.pendingQuestions > 0) { + return { ...base, mood: 'waiting', state: 'attention', labelKey: 'agentCompanion.activity.needsInput', defaultLabel: 'Needs input' }; + } + if (activity.execution === 'running' || activity.execution === 'queued') { + return { ...base, mood: 'working', state: 'running', labelKey: 'agentCompanion.activity.working', defaultLabel: 'Working' }; + } + if (activity.unreadCompletion === 'completed') { + return { ...base, mood: 'rest', state: 'completed', labelKey: 'agentCompanion.activity.completed', defaultLabel: 'Completed' }; + } + if (activity.unreadCompletion === 'interrupted') { + return { ...base, mood: 'rest', state: 'interrupted', labelKey: 'agentCompanion.activity.interrupted', defaultLabel: 'Interrupted' }; + } + if (activity.unreadCompletion === 'error' || activity.execution === 'error') { + return { ...base, mood: 'rest', state: 'error', labelKey: 'agentCompanion.activity.failed', defaultLabel: 'Failed' }; + } + return null; +} + export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { const sessions = Array.from(FlowChatStore.getInstance().getState().sessions.values()) .filter(isIndependentCompanionSession); @@ -402,6 +460,17 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { } }); + // Other devices' sessions coexist with this one's in the companion. + const listed = new Set(tasks.map(task => task.sessionId)); + for (const row of listBackgroundDeviceSessions()) { + if (listed.has(row.session.sessionId)) continue; + const task = backgroundDeviceTask(row); + if (task) { + tasks.push(task); + listed.add(task.sessionId); + } + } + if (!tasks.length) { pruneTaskOrder(tasks); return EMPTY_ACTIVITY; @@ -427,17 +496,26 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { export function subscribeAgentCompanionActivity( listener: (payload: AgentCompanionActivityPayload) => void, ): () => void { + // Stores notify far more often than the companion's facts change (streaming + // tokens, catalog page reads); only a changed payload crosses to its window. + let last = ''; const emitCurrent = () => { - listener(buildAgentCompanionActivity()); + const activity = buildAgentCompanionActivity(); + const key = JSON.stringify(activity); + if (key === last) return; + last = key; + listener(activity); }; const unsubscribeStore = FlowChatStore.getInstance().subscribe(emitCurrent); const unsubscribeMachines = stateMachineManager.subscribeGlobal(emitCurrent); + const unsubscribeDevices = subscribeCrossDeviceSessions(emitCurrent); emitCurrent(); return () => { unsubscribeStore(); unsubscribeMachines(); + unsubscribeDevices(); }; } diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 38762c20a6..7eaf764993 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -1083,3 +1083,28 @@ function captureOutcome(request: Promise) { function flushMicrotasks(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } + +describe('background requests during a cross-device open', () => { + it('holds low-priority reads until the open settles while session reads proceed', async () => { + const { beginDeviceOpen, endDeviceOpen } = await import('@/infrastructure/peer-device/deviceOpenIntent'); + const ok = JSON.stringify({ resp: 'host_invoke_result', ok: true, value: [] }); + const rpc = vi.fn().mockResolvedValue(ok); + const adapter = new PeerDeviceTransportAdapter('peer-gate', rpc); + const intent = beginDeviceOpen({ surfaceId: 'peer-gate', key: 'session:s', sessionId: 's' }); + try { + const background = adapter.request('git_get_status', { request: {} }); + await adapter.request('set_active_workspace', { request: { workspaceId: 'w' } }); + const commands = () => rpc.mock.calls.map(call => JSON.parse(call[1]).command); + expect(commands()).toEqual(['set_active_workspace']); + endDeviceOpen(intent); + await background; + expect(commands()).toEqual(['set_active_workspace', 'git_get_status']); + } finally { + endDeviceOpen(intent); + } + }); + + it('activates a session workspace at high priority', () => { + expect(peerInvokePriorityFor('set_active_workspace')).toBe('high'); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index a955093a2e..7f73c87b8d 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -2,6 +2,7 @@ import { DEFAULT_RPC_TIMEOUT_MS } from '../../../../../shared/relay-transport/Rp import { translateAgentIdentityCommand, translateAgentIdentityFields, translateAgentIdentityResponse } from '../../../../../shared/agent-harness/wire'; import { ITransportAdapter, type TransportRequestTiming } from './base'; import { TauriTransportAdapter } from './tauri-adapter'; +import { getDeviceOpenIntent, useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; import { SurfaceChangedError, getActiveSurfaceScope, @@ -60,6 +61,8 @@ const HIGH_PRIORITY_COMMANDS = new Set([ 'get_current_workspace', 'worktree_list_projects', 'open_workspace', + // A session open activates its workspace; it must not wait behind data reads. + 'set_active_workspace', 'get_workspace_info', 'reload_config', 'get_config', @@ -210,6 +213,18 @@ export function isPeerRetryableIdempotentMutation( export type PeerInvokePriority = 'high' | 'normal' | 'low'; +/** + * How long a cross-device open may hold background requests. A slow host must + * not starve them: past this, they share the link again. + */ +const DEVICE_OPEN_LOW_PRIORITY_HOLD_MS = 8_000; + +/** Milliseconds the running open may still hold background requests, or 0. */ +function deviceOpenHoldRemaining(): number { + const intent = getDeviceOpenIntent(); + return intent ? Math.max(0, intent.startedAt + DEVICE_OPEN_LOW_PRIORITY_HOLD_MS - (typeof performance !== 'undefined' ? performance.now() : Date.now())) : 0; +} + const LOW_PRIORITY_EXACT = new Set([ 'get_file_metadata', 'read_file_content', @@ -832,7 +847,33 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { private dequeueNext(): QueuedPeerRequest | undefined { // Order the current dispatch burst without a second in-flight limit. // Slow data requests cannot hold control calls behind adapter-owned slots. - return this.queues.high.shift() ?? this.queues.normal.shift() ?? this.queues.low.shift(); + const next = this.queues.high.shift() ?? this.queues.normal.shift(); + if (next) return next; + // While a cross-device open runs, background work (git, search, file + // tree, watchers, mini apps) waits for it instead of sharing the link with + // the session being opened. It is released when the open settles. + const hold = this.queues.low.length > 0 ? deviceOpenHoldRemaining() : 0; + if (hold > 0) { + this.resumeLowAfterOpen(hold); + return undefined; + } + return this.queues.low.shift(); + } + + private lowResumeArmed = false; + + private resumeLowAfterOpen(hold: number): void { + if (this.lowResumeArmed) return; + this.lowResumeArmed = true; + const release: Array<() => void> = []; + const resume = () => { + for (const dispose of release.splice(0)) dispose(); + this.lowResumeArmed = false; + this.pump(); + }; + const timer = setTimeout(resume, hold); + release.push(() => clearTimeout(timer)); + release.push(useDeviceOpenIntent.subscribe(state => { if (!state.intent) resume(); })); } private async invokePeerCommand( diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index b713d1731d..9b966263d8 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:ad57aa872484f2a8" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:ff25ac8314514c25" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index e7da56890a..5cfef5cab1 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -181,6 +181,8 @@ import { imageViewerAppearanceDescriptor } from '@/tools/editor/components/Image import { pdfViewerAppearanceDescriptor } from '@/tools/editor/components/PdfViewer.appearance'; import { branchQuickSwitchAppearanceDescriptor } from '@/tools/git/components/BranchQuickSwitch.appearance'; import { workspaceListSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance'; +import { navDeviceSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance'; +import { deviceSwitchCurtainAppearanceDescriptor } from '@/app/scenes/device-switch/DeviceSwitchCurtain.appearance'; import { workspaceIconAppearanceDescriptor, workspaceIconEditorAppearanceDescriptor } from '@/app/components/WorkspaceIcon/appearance'; import { workspaceItemAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance'; import { newProjectDialogAppearanceDescriptor } from '@/app/components/NewProjectDialog/NewProjectDialog.appearance'; @@ -453,6 +455,8 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(pdfViewerAppearanceDescriptor) .registerComponent(branchQuickSwitchAppearanceDescriptor) .registerComponent(workspaceListSectionAppearanceDescriptor) + .registerComponent(navDeviceSectionAppearanceDescriptor) + .registerComponent(deviceSwitchCurtainAppearanceDescriptor) .registerComponent(workspaceIconAppearanceDescriptor) .registerComponent(workspaceIconEditorAppearanceDescriptor) .registerComponent(workspaceItemAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx index 94fcc4bf24..973d6bf457 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MenuItem } from '@/shared/context-menu-system/types'; import { MarkdownRenderer, ThinkingMarkdownRenderer } from './MarkdownRenderer'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores/canvasStore'; -import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { selectSessionPaneMode, sessionPaneLayoutStore } from '@/app/scenes/session/sessionPaneLayoutStore'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; import { appManager } from '@/app/services/AppManager'; @@ -196,7 +196,7 @@ describe('Markdown file links', () => { activateSurface('local'); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); container = document.createElement('div'); @@ -227,7 +227,7 @@ describe('Markdown file links', () => { container.remove(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); vi.clearAllMocks(); }); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts index acafebaf3c..2429d6363d 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts @@ -154,6 +154,8 @@ class FakeConnectionManager { function createHarness(options: { rebootstrap?: (scope: SurfaceScope) => Promise; + reloadConfig?: (scope: SurfaceScope) => Promise; + deferConfigReload?: () => boolean; } = {}) { const manager = new FakeConnectionManager(); const commits: string[] = []; @@ -169,7 +171,8 @@ function createHarness(options: { connectionManager: manager, getLocalAdapter: async () => adapter(), resetProductSurface: async assertCurrent => assertCurrent(), - reloadConfig: async scope => scope.assertCurrent('reload config'), + reloadConfig: options.reloadConfig ?? (async scope => scope.assertCurrent('reload config')), + deferConfigReload: options.deferConfigReload, rebootstrapWorkspaces: options.rebootstrap ?? (async scope => scope.assertCurrent('rebootstrap workspaces')), setPeerControllerActive: async active => { @@ -255,6 +258,15 @@ describe('PeerDeviceSurfaceController switching', () => { expect(harness.snapshot().peerMode).toMatchObject({ active: true, deviceId: 'peer-a' }); }); + it('does not hold a switch on config while a cross-device open waits for it', async () => { + const config = deferred(); + const reloadConfig = vi.fn(() => config.promise); + const harness = createHarness({ reloadConfig, deferConfigReload: () => true }); + await expect(harness.controller.switchToDevice('peer-a', 'A')).resolves.toBe('activated'); + expect(reloadConfig).toHaveBeenCalledOnce(); + config.resolve(); + }); + it('coalesces a rapid switch to the last target before the first connection completes', async () => { const harness = createHarness(); harness.manager.delay('peer-a'); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts index 375014b7bd..3804e7575a 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts @@ -69,6 +69,12 @@ export interface PeerDeviceSurfaceControllerDependencies { getLocalAdapter: () => Promise; resetProductSurface: (assertCurrent: () => void) => Promise; reloadConfig: (scope: SurfaceScope) => Promise; + /** + * True while a cross-device open waits for this switch: the device's config + * then reloads after the switch instead of holding it, since the session + * the user opened does not need it to show. + */ + deferConfigReload?: () => boolean; rebootstrapWorkspaces: (scope: SurfaceScope) => Promise; setPeerControllerActive: (active: boolean, required: boolean) => Promise; commitSurface: (target: ResolvedSurfaceTarget) => SurfaceScope; @@ -427,18 +433,28 @@ export class PeerDeviceSurfaceController { // catalog is authoritative. this.dependencies.setPeerDeviceModeActiveFlag(true); - await this.awaitIntent( - intent, - this.dependencies.reloadConfig(activationScope), - activationScope, - 'reload device surface config', - ); - await this.awaitIntent( - intent, - this.dependencies.rebootstrapWorkspaces(activationScope), - activationScope, - 'rebootstrap device surface workspaces', - ); + // Config and the workspace catalog are independent reads of the same host; + // fetching them together halves the round trips a switch waits for. + const deferConfig = this.dependencies.deferConfigReload?.() === true; + if (deferConfig) { + void this.dependencies.reloadConfig(activationScope).catch(error => { + if (!isSurfaceChangedError(error)) log.warn('Deferred device config reload failed', { error }); + }); + } + await Promise.all([ + deferConfig ? Promise.resolve() : this.awaitIntent( + intent, + this.dependencies.reloadConfig(activationScope), + activationScope, + 'reload device surface config', + ), + this.awaitIntent( + intent, + this.dependencies.rebootstrapWorkspaces(activationScope), + activationScope, + 'rebootstrap device surface workspaces', + ), + ]); if (target.deviceId === null) { await this.awaitIntent( diff --git a/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts new file mode 100644 index 0000000000..f820dda755 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts @@ -0,0 +1,117 @@ +/** + * Read-only product reads on a device the window is not rendering. + * + * The product transport is bound to the rendered surface: a peer adapter that + * is not rendered refuses requests, and `api.invoke` targets whatever device is + * rendered. The sidebar device catalog must read every other device without + * changing what the window draws, so it talks to each host directly: + * + * - a peer through a raw `host_invoke` device RPC (no control attachment, no + * event fan-out; the host's Product Operation Registry still decides what + * it may run); + * - this machine through its own Tauri transport. + * + * Only the catalog reads below may use this path, plus one acknowledgement + * write (`acknowledgeOnDevice`): clearing a session's read state touches no + * workspace, tool or permission, and asking the user to render every device + * to mark it read would defeat the action. Every other mutation goes through + * the rendered surface after activation so it keeps Peer Device Mode's + * capability gates, permission mailboxes and surface fences. + */ + +import { TauriTransportAdapter } from '@/infrastructure/api/adapters/tauri-adapter'; +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { + translateAgentIdentityCommand, + translateAgentIdentityResponse, +} from '../../../../shared/agent-harness/wire'; + +export const DEVICE_CATALOG_READ_COMMANDS = new Set([ + 'peer_mode_ping', + 'get_opened_workspaces', + 'list_persisted_sessions_page', +]); + +const DEVICE_CATALOG_RPC_TIMEOUT_MS = 15_000; + +interface HostInvokeEnvelope { + resp?: string; + ok?: boolean; + value?: unknown; + error?: string; + message?: string; +} + +/** A host answered and refused the command; the link itself is healthy. */ +export class DeviceCatalogCommandError extends Error { + constructor(message: string, readonly command: string) { + super(message); + this.name = 'DeviceCatalogCommandError'; + } +} + +let localTransport: TauriTransportAdapter | null = null; + +/** Never disposed with a surface: background reads outlive every switch. */ +export function deviceCatalogLocalTransport(): TauriTransportAdapter { + localTransport ??= new TauriTransportAdapter(); + return localTransport; +} + +/** The only metadata fields `acknowledgeOnDevice` may write. */ +export const ACKNOWLEDGE_FIELDS = ['unreadCompletion', 'needsUserAttention'] as const; + +/** Clear a session's read state on a device the window may not render. */ +export function acknowledgeOnDevice( + deviceId: string | null, + workspaceRequest: Record, + metadata: Record, +): Promise { + return send(deviceId, 'save_session_metadata', { + request: { + ...workspaceRequest, + metadata: { ...metadata, unreadCompletion: undefined, needsUserAttention: undefined }, + fields: [...ACKNOWLEDGE_FIELDS], + }, + }); +} + +/** + * Run one catalog read on `deviceId`, or on this machine when it is `null`. + */ +export async function invokeOnDevice( + deviceId: string | null, + command: string, + args: Record = {}, +): Promise { + if (!DEVICE_CATALOG_READ_COMMANDS.has(command)) { + throw new Error(`'${command}' is not a device catalog read`); + } + return send(deviceId, command, args); +} + +async function send(deviceId: string | null, command: string, args: Record): Promise { + if (deviceId === null) { + return deviceCatalogLocalTransport().request(command, args); + } + const raw = await remoteConnectAPI.accountDeviceRpc( + deviceId, + JSON.stringify({ + cmd: 'host_invoke', + command, + args: translateAgentIdentityCommand(command, args, 'legacy'), + }), + DEVICE_CATALOG_RPC_TIMEOUT_MS, + ); + const envelope = JSON.parse(raw) as HostInvokeEnvelope; + if (envelope.resp === 'error') { + throw new Error(envelope.message || `Device '${command}' failed`); + } + if (envelope.resp !== 'host_invoke_result') { + throw new Error(`Unexpected device RPC response for '${command}': ${envelope.resp || 'unknown'}`); + } + if (!envelope.ok) { + throw new DeviceCatalogCommandError(envelope.error || `Device '${command}' failed`, command); + } + return translateAgentIdentityResponse(command, envelope.value, args, 'canonical') as T; +} diff --git a/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts b/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts new file mode 100644 index 0000000000..8cccb58fe5 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from './deviceSurface'; + +/** + * The cross-device open the user just asked for. Set on click, before the + * device switch starts, and cleared when the open settles. + * + * Several owners read it: the clicked row highlights from it, navigation keeps + * drawing background projections until it ends (one hand-over, after the + * open), the device catalog defers its refreshes so they never compete with + * the open for the relay link, and the switch lands on the session scene + * instead of an empty workbench. + */ +export interface DeviceOpenIntent { + surfaceId: DeviceSurfaceId; + /** `session:`, `assistant:` or `new:`. */ + key: string; + /** Present when the open ends in a known session. */ + sessionId?: string; + title?: string; + startedAt: number; +} + +export const useDeviceOpenIntent = create<{ intent: DeviceOpenIntent | null }>(() => ({ intent: null })); + +export function beginDeviceOpen(intent: Omit): DeviceOpenIntent { + const next = { ...intent, startedAt: typeof performance !== 'undefined' ? performance.now() : Date.now() }; + useDeviceOpenIntent.setState({ intent: next }); + return next; +} + +export function endDeviceOpen(intent: DeviceOpenIntent): void { + if (useDeviceOpenIntent.getState().intent === intent) useDeviceOpenIntent.setState({ intent: null }); +} + +export function getDeviceOpenIntent(): DeviceOpenIntent | null { + return useDeviceOpenIntent.getState().intent; +} diff --git a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts index fb53133ee3..83356fb732 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts @@ -47,6 +47,8 @@ const SURFACE_SCOPED_EVENTS = new Set([ 'ai://model-catalog-updated', 'session_title_generated', 'workspace-catalog-changed', + // Catalog stream hint of one host; the sidebar device catalog observes it. + 'host-catalog-changed', 'cron://jobs-changed', ]); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts new file mode 100644 index 0000000000..2361e639df --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts @@ -0,0 +1,383 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); +vi.mock('./deviceCatalogTransport', () => ({ invokeOnDevice: vi.fn(), acknowledgeOnDevice: vi.fn(), deviceCatalogLocalTransport: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: {} })); + +import { + DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS, + DEVICE_CATALOG_POLL_MS, + DEVICE_CATALOG_RELEASE_GRACE_MS, + DeviceWorkspaceCatalog, + type DeviceWorkspaceCatalogIO, +} from './deviceWorkspaceCatalog'; +import { routeSurfaceEvent, setActiveSurfaceDeviceId } from './deviceSurfaceRouting'; +import { activateSurface, resetDeviceSurfaceForTest, surfaceIdForDevice } from './deviceSurface'; +import { beginDeviceOpen, endDeviceOpen } from './deviceOpenIntent'; + +const workspace = (id: string, extra: Record = {}) => ({ + id, name: id, rootPath: `/home/me/${id}`, workspaceType: 'other', workspaceKind: 'normal', + languages: [], openedAt: '', lastAccessed: '', tags: [], ...extra, +}); + +const session = (sessionId: string, extra: Record = {}) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt: 1, lastActiveAt: 1, + turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], ...extra, +}); + +async function flush(): Promise { + for (let i = 0; i < 20; i += 1) await Promise.resolve(); +} + +describe('DeviceWorkspaceCatalog', () => { + let invoke: ReturnType; + let io: DeviceWorkspaceCatalogIO; + let catalog: DeviceWorkspaceCatalog; + let capabilities: Record; + let workspaces: unknown[]; + + beforeEach(() => { + vi.useFakeTimers(); + resetDeviceSurfaceForTest(); + capabilities = { workspace_id_references_v1: true }; + workspaces = [workspace('ws-1'), workspace('assistant-1', { workspaceKind: 'assistant' })]; + invoke = vi.fn(async (_device: string | null, command: string) => { + if (command === 'peer_mode_ping') return { capabilities }; + if (command === 'get_opened_workspaces') return workspaces; + if (command === 'list_persisted_sessions_page') { + return { + sessions: [ + session('s-1'), + session('child', { relationship: { parentSessionId: 's-1' } }), + session('sub', { sessionKind: 'subagent' }), + session('archived', { status: 'archived' }), + ], + totalTopLevelCount: 1, loadedTopLevelCount: 1, hasMore: false, + activities: [{ sessionId: 's-1', execution: 'running', pendingApprovals: 0, pendingQuestions: 0 }], + }; + } + throw new Error(`unexpected ${command}`); + }); + io = { + invoke: invoke as DeviceWorkspaceCatalogIO['invoke'], + acknowledge: vi.fn(async () => undefined), + subscribeCatalog: vi.fn(async () => 'sub-1'), + unsubscribeCatalog: vi.fn(async () => undefined), + listen: vi.fn(() => () => undefined), + }; + catalog = new DeviceWorkspaceCatalog(io); + }); + + afterEach(() => { + catalog.dispose(); + setActiveSurfaceDeviceId(null); + resetDeviceSurfaceForTest(); + vi.useRealTimers(); + }); + + it('reads nothing until a section retains the device', async () => { + expect(catalog.getSnapshot('peer-a').status).toBe('idle'); + expect(invoke).not.toHaveBeenCalled(); + const release = catalog.retain('peer-a'); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).toEqual(['peer_mode_ping', 'get_opened_workspaces']); + expect(invoke.mock.calls.every(call => call[0] === 'peer-a')).toBe(true); + expect(catalog.getSnapshot('peer-a').workspaces.map(item => item.id)).toEqual(['ws-1', 'assistant-1']); + expect(io.subscribeCatalog).toHaveBeenCalledWith('peer-a'); + expect(catalog.getSnapshot('peer-a').live).toBe(true); + release(); + }); + + it('loads sessions only for an expanded workspace and keeps top-level rows', async () => { + catalog.retain('peer-a'); + await flush(); + expect(invoke).not.toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', expect.anything()); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', { + request: { workspace_id: 'ws-1', limit: 8 }, + }); + const sessions = catalog.getSessions('peer-a', 'ws-1'); + expect(sessions.sessions.map(item => item.sessionId)).toEqual(['s-1']); + expect(sessions.activities['s-1']?.execution).toBe('running'); + }); + + it('addresses a pre-ID host by its own workspace root', async () => { + capabilities = {}; + workspaces = [workspace('ws-remote', { workspaceKind: 'remote', connectionId: 'conn-1', sshHost: 'box' })]; + catalog.retain('peer-old'); + await flush(); + catalog.retainSessions('peer-old', 'ws-remote'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-old', 'list_persisted_sessions_page', { + request: { workspace_path: '/home/me/ws-remote', remote_connection_id: 'conn-1', remote_ssh_host: 'box', limit: 8 }, + }); + }); + + it('reads this machine through the local transport without a stream', async () => { + catalog.retain('local'); + await flush(); + expect(invoke.mock.calls.map(call => [call[0], call[1]])).toEqual([[null, 'get_opened_workspaces']]); + expect(io.subscribeCatalog).not.toHaveBeenCalled(); + }); + + it('coalesces a burst of host hints into one debounced read', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + for (let i = 0; i < 5; i += 1) { + routeSurfaceEvent('host-catalog-changed', { sessionsRevision: i, __openbitfunSourceDeviceId: 'peer-a' }); + } + expect(invoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + }); + + it('ignores hints from the rendered device and from devices nobody shows', async () => { + catalog.retain('peer-a'); + await flush(); + activateSurface(surfaceIdForDevice('peer-a')); + setActiveSurfaceDeviceId('peer-a'); + // An activation refreshes every catalog once; hints after that are the subject. + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + invoke.mockClear(); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-b' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 2); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('keeps the last catalog on failure and recovers on retry', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockImplementation(async () => { throw new Error('host unreachable'); }); + await catalog.retry('peer-a'); + expect(catalog.getSnapshot('peer-a').workspaces).toHaveLength(2); + expect(catalog.getSnapshot('peer-a').error).toBe('host unreachable'); + invoke.mockImplementation(async (_device: string | null, command: string) => ( + command === 'get_opened_workspaces' ? [workspace('ws-2')] : { capabilities } + )); + await catalog.retry('peer-a'); + expect(catalog.getSnapshot('peer-a').error).toBeUndefined(); + expect(catalog.getSnapshot('peer-a').workspaces.map(item => item.id)).toEqual(['ws-2']); + }); + + it('reports an error state when the first read fails', async () => { + invoke.mockRejectedValue(new Error('offline')); + catalog.retain('peer-a'); + await flush(); + expect(catalog.getSnapshot('peer-a').status).toBe('error'); + }); + + it('stops the host stream only after the release grace period', async () => { + const release = catalog.retain('peer-a'); + await flush(); + release(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS - 1); + expect(io.unsubscribeCatalog).not.toHaveBeenCalled(); + const again = catalog.retain('peer-a'); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS); + expect(io.unsubscribeCatalog).not.toHaveBeenCalled(); + again(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS); + expect(io.unsubscribeCatalog).toHaveBeenCalledWith('sub-1'); + }); + + it('falls back to visibility refresh for a host without a catalog stream', async () => { + io.subscribeCatalog = vi.fn(async () => { throw new Error('host_stream_v1 unsupported'); }); + catalog.retain('peer-a'); + await flush(); + expect(catalog.getSnapshot('peer-a').live).toBe(false); + invoke.mockClear(); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + invoke.mockClear(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_POLL_MS + DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('re-reads open sections under a new account', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + catalog.reset(); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'peer_mode_ping', {}); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('keeps every workspace current for device status without expanding it', async () => { + workspaces = [workspace('ws-1'), workspace('ws-2'), workspace('ws-3')]; + const release = catalog.retainActivity('peer-a'); + await flush(); + const sessionReads = invoke.mock.calls.filter(call => call[1] === 'list_persisted_sessions_page'); + expect(sessionReads.map(call => call[2].request.workspace_id).sort()).toEqual(['ws-1', 'ws-2', 'ws-3']); + expect(catalog.loadedSurfaces()).toEqual(['peer-a']); + + // An expanded row reuses the page the activity refresh keeps current. + invoke.mockClear(); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + expect(invoke).not.toHaveBeenCalled(); + + // A host hint refreshes all of them once, after the debounce. + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'list_persisted_sessions_page')).toHaveLength(3); + release(); + }); + + it('refreshes catalogs once a device switch lands', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + activateSurface(surfaceIdForDevice('peer-b')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('holds refreshes while a cross-device open runs and catches up once after it', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + const intent = beginDeviceOpen({ surfaceId: 'peer-b', key: 'session:s' }); + activateSurface(surfaceIdForDevice('peer-b')); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 4); + expect(invoke).not.toHaveBeenCalled(); + endDeviceOpen(intent); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + }); + + it('refreshes only session pages when the hint says the workspace list is unchanged', async () => { + catalog.retainActivity('peer-a'); + await flush(); + const hint = (workspacesRevision: number) => routeSurfaceEvent('host-catalog-changed', + { sessionsRevision: Math.random(), workspacesRevision, __openbitfunSourceDeviceId: 'peer-a' }); + hint(1); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + invoke.mockClear(); + hint(1); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).not.toContain('get_opened_workspaces'); + expect(invoke.mock.calls.map(call => call[1])).toContain('list_persisted_sessions_page'); + invoke.mockClear(); + hint(2); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).toContain('get_opened_workspaces'); + }); + + it('reads nothing while the window is hidden and catches up once when shown', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + for (let i = 0; i < 3; i += 1) routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 3); + expect(invoke).not.toHaveBeenCalled(); + visibility.mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + visibility.mockRestore(); + }); + + it('does not re-read the device that just became rendered', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + activateSurface(surfaceIdForDevice('peer-a')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('coalesces the updates of a read into fewer notifications', async () => { + workspaces = [workspace('ws-1'), workspace('ws-2'), workspace('ws-3')]; + const listener = vi.fn(); + catalog.subscribe(listener); + catalog.retainActivity('peer-a'); + await flush(); + await flush(); + // Unbatched, a read of three workspaces publishes 9 updates (status, list, + // and loading/ready per page); updates within one task share a notification. + expect(listener.mock.calls.length).toBeLessThan(9); + }); + + it('marks a device read on its host, updating rows at once', async () => { + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'peer_mode_ping') return { capabilities }; + if (command === 'get_opened_workspaces') return [workspace('ws-1')]; + return { + sessions: [session('done', { unreadCompletion: 'completed' }), session('quiet')], + totalTopLevelCount: 2, loadedTopLevelCount: 2, hasMore: false, + activities: [{ sessionId: 'done', execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, unreadCompletion: 'completed' }], + }; + }); + catalog.retainActivity('peer-a'); + await flush(); + const marked = catalog.markAllRead('peer-a'); + // Optimistic: cleared before the host answers. + expect(catalog.getSessions('peer-a', 'ws-1').activities.done?.unreadCompletion).toBeUndefined(); + expect(await marked).toBe(1); + expect(io.acknowledge).toHaveBeenCalledOnce(); + expect(io.acknowledge).toHaveBeenCalledWith('peer-a', { workspace_id: 'ws-1' }, expect.objectContaining({ sessionId: 'done' })); + }); + + it('drops the answer of a session read a newer read replaced', async () => { + catalog.retain('peer-a'); + await flush(); + let releaseSlow: (value: unknown) => void = () => undefined; + let calls = 0; + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'get_opened_workspaces') return workspaces; + if (command !== 'list_persisted_sessions_page') return { capabilities }; + calls += 1; + if (calls === 1) return new Promise(resolve => { releaseSlow = resolve; }); + return { sessions: [session('new'), session('older')], totalTopLevelCount: 2, hasMore: false }; + }); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + void catalog.loadMoreSessions('peer-a', 'ws-1'); + await flush(); + releaseSlow({ sessions: [session('stale')], totalTopLevelCount: 1, hasMore: false }); + await flush(); + expect(catalog.getSessions('peer-a', 'ws-1').sessions.map(item => item.sessionId)).toEqual(['new', 'older']); + }); + + it('asks a host that refused a session read how it addresses sessions again', async () => { + catalog.retain('peer-a'); + await flush(); + const refused = Object.assign(new Error('unknown field workspace_id'), { name: 'DeviceCatalogCommandError' }); + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'get_opened_workspaces') return workspaces; + if (command === 'peer_mode_ping') return { capabilities: {} }; + throw refused; + }); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + invoke.mockClear(); + await catalog.retry('peer-a'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'peer_mode_ping', {}); + expect(invoke).toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', { + request: expect.objectContaining({ workspace_path: '/home/me/ws-1' }), + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts new file mode 100644 index 0000000000..11f3b9df91 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts @@ -0,0 +1,747 @@ +/** + * Workspace and session catalog of devices the window is not rendering. + * + * The sidebar lists every online host of the account in place. The rendered + * device keeps its live workspace container; every other device is described + * by this read-only catalog, which is loaded lazily and kept current by host + * invalidations: + * + * - Nothing is read until a section is expanded (`retain`). Sessions of a + * workspace are read only while that workspace is expanded. + * - A peer's `@host/catalog` stream sends revision hints (no data). Local + * catalog and lifecycle events cover this machine and attached peers. + * Hints are debounced, then coalesced into one in-flight read per device. + * - A host that cannot stream (older build) is re-read when the window becomes + * visible again; there is no polling. + * - A failed read keeps the last rendered catalog and exposes the error for + * retry. An offline device is never substituted by a cache of another host. + * + * Opening anything from this catalog activates the owning surface first, so + * every operation keeps Peer Device Mode's ownership and capability rules. + */ + +import { upgradeLegacyWorktreeReferences } from '@/infrastructure/api/service-api/legacyWorkspaceCompatibility'; +import type { WorkspaceInfo as APIWorkspaceInfo } from '@/infrastructure/api/service-api/GlobalAPI'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { mapWorkspaceInfo, type WorkspaceInfo } from '@/shared/types/global-state'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { createLogger } from '@/shared/utils/logger'; +import { InvalidationSync } from '../../../../shared/relay-transport/InvalidationSync'; +import { HOST_CATALOG_ID } from '../../../../shared/relay-transport/HostStream'; +import { + getActiveSurfaceId, + isLocalSurface, + onSurfaceActivated, + surfaceIdForDevice, + type DeviceSurfaceId, +} from './deviceSurface'; +import { observeSurfaceEvents } from './deviceSurfaceRouting'; +import { acknowledgeOnDevice, deviceCatalogLocalTransport, invokeOnDevice } from './deviceCatalogTransport'; +import { stableWorkspaceOrder } from '@/infrastructure/services/business/workspaceDisplayOrder'; +import { getDeviceOpenIntent, useDeviceOpenIntent } from './deviceOpenIntent'; + +const log = createLogger('DeviceWorkspaceCatalog'); + +// The live list's first rows (3) plus one "show more" step (5): the same rows +// draw at once, and the first expansion needs no read. +export const DEVICE_CATALOG_SESSION_PAGE = 8; +export const DEVICE_CATALOG_SESSION_STEP = 20; +const MAX_REFRESHED_SESSIONS = 200; +const MAX_PAGES_PER_READ = 5; +/** Session reads one device runs at once; a refresh never floods its relay link. */ +const SESSION_READ_CONCURRENCY = 2; +export const DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS = 300; +/** How often a device whose host has no catalog stream is re-read while shown. */ +export const DEVICE_CATALOG_POLL_MS = 60_000; +export const DEVICE_CATALOG_RELEASE_GRACE_MS = 30_000; + +/** + * Events that can change a host's workspace list, session list or ordering. + * `host-catalog-changed` is the peer catalog stream hint; the rest are the + * lifecycle events this machine (and any attached peer) already fans out. + */ +const CATALOG_INVALIDATING_EVENTS = new Set([ + 'host-catalog-changed', + 'workspace-catalog-changed', + 'agentic://session-created', + 'agentic://session-deleted', + 'agentic://dialog-turn-started', + 'agentic://dialog-turn-completed', + 'session_title_generated', +]); + +export type DeviceCatalogStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface DeviceCatalogSessions { + readonly status: DeviceCatalogStatus; + /** Top-level sessions in host order, children excluded. */ + readonly sessions: readonly SessionMetadata[]; + /** Every non-archived record of the read, children included (navigation projection). */ + readonly records: readonly SessionMetadata[]; + readonly activities: Readonly>; + readonly hasMore: boolean; + /** Top-level count when the host reported a complete count. */ + readonly total: number | null; + readonly limit: number; + readonly error?: string; +} + +export interface DeviceCatalogSnapshot { + readonly status: DeviceCatalogStatus; + readonly workspaces: readonly WorkspaceInfo[]; + readonly error?: string; + /** The host streams catalog hints; false means it refreshes on visibility. */ + readonly live: boolean; + readonly sessions: Readonly>; +} + +const EMPTY_SNAPSHOT: DeviceCatalogSnapshot = Object.freeze({ + status: 'idle', + workspaces: [], + live: false, + sessions: {}, +}); + +const EMPTY_SESSIONS: DeviceCatalogSessions = Object.freeze({ + status: 'idle', + sessions: [], + records: [], + activities: {}, + hasMore: false, + total: null, + limit: DEVICE_CATALOG_SESSION_PAGE, +}); + +interface Entry { + readonly surfaceId: DeviceSurfaceId; + readonly deviceId: string | null; + readonly generation: number; + snapshot: DeviceCatalogSnapshot; + retainCount: number; + releaseTimer: ReturnType | null; + debounceTimer: ReturnType | null; + readonly workspaceRetain: Map; + /** Keep every workspace's first page current (device status, companion). */ + activityRetain: number; + readonly sync: InvalidationSync; + /** `workspace_id_references_v1`; null until the host answered a ping. */ + workspaceIdReferences: boolean | null; + /** The ping in flight, shared by every read that needs its answer. */ + negotiation: Promise | null; + /** Latest session read per workspace; an older read's answer is dropped. */ + readonly sessionReads: Map; + streamSubscriptionId: string | null; + streamPending: boolean; + stopped: boolean; + /** Whether the next read must re-read the workspace list or only session pages. */ + pendingScope: RefreshScope; + /** Last `workspacesRevision` a host hint carried. */ + workspacesRevision: unknown; +} + +/** `sessions`: workspace list unchanged, refresh session pages only. */ +type RefreshScope = 'sessions' | 'all'; +const mergeScope = (a: RefreshScope | undefined, b: RefreshScope): RefreshScope => (a === 'all' || b === 'all' ? 'all' : 'sessions'); + +type Listener = (surfaceId: DeviceSurfaceId) => void; + +export interface DeviceWorkspaceCatalogIO { + invoke: (deviceId: string | null, command: string, args: Record) => Promise; + acknowledge: (deviceId: string | null, workspaceRequest: Record, metadata: Record) => Promise; + subscribeCatalog: (deviceId: string) => Promise; + unsubscribeCatalog: (subscriptionId: string) => Promise; + /** Registers the native listeners that feed `observeSurfaceEvents`. */ + listen: (event: string, callback: (payload: unknown) => void) => () => void; +} + +const defaultIO: DeviceWorkspaceCatalogIO = { + invoke: invokeOnDevice, + acknowledge: acknowledgeOnDevice, + subscribeCatalog: deviceId => remoteConnectAPI.subscribeSession(deviceId, HOST_CATALOG_ID), + unsubscribeCatalog: subscriptionId => remoteConnectAPI.unsubscribeSession(subscriptionId), + listen: (event, callback) => deviceCatalogLocalTransport().listen(event, callback), +}; + +function isTopLevel(session: SessionMetadata): boolean { + return session.sessionKind !== 'subagent' && !session.relationship?.parentSessionId; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class DeviceWorkspaceCatalog { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private generation = 0; + private teardownEvents: (() => void) | null = null; + private readonly deferred = new Map(); + private readonly notifyQueue = new Set(); + + constructor(private readonly io: DeviceWorkspaceCatalogIO = defaultIO) {} + + getSnapshot(surfaceId: DeviceSurfaceId): DeviceCatalogSnapshot { + return this.entries.get(surfaceId)?.snapshot ?? EMPTY_SNAPSHOT; + } + + getSessions(surfaceId: DeviceSurfaceId, workspaceId: string): DeviceCatalogSessions { + return this.getSnapshot(surfaceId).sessions[workspaceId] ?? EMPTY_SESSIONS; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + /** + * Keep a device catalog loaded while a section shows it. The first retain + * reads the host; releasing keeps the last catalog for an instant reopen and + * stops the host stream after a grace period. + */ + retain(surfaceId: DeviceSurfaceId): () => void { + this.installEvents(); + const entry = this.entryFor(surfaceId); + entry.retainCount += 1; + if (entry.releaseTimer !== null) { + clearTimeout(entry.releaseTimer); + entry.releaseTimer = null; + } + if (entry.retainCount === 1) { + this.startStream(entry); + void this.invalidateNow(entry); + } + let released = false; + return () => { + if (released) return; + released = true; + // Look the entry up again: an account reset may have replaced it. + const current = this.entries.get(surfaceId); + if (current) this.release(current); + }; + } + + /** + * Keep the latest sessions of every workspace on a device current, so its + * running, waiting and unread sessions are known without expanding anything. + * Implies `retain`. Reads stay invalidation-driven and debounced. + */ + retainActivity(surfaceId: DeviceSurfaceId): () => void { + const releaseCatalog = this.retain(surfaceId); + const entry = this.entryFor(surfaceId); + entry.activityRetain += 1; + if (entry.activityRetain === 1 && entry.snapshot.status === 'ready') { + void this.loadSessionPages(entry, entry.snapshot.workspaces.map(workspace => workspace.id), true); + } + let released = false; + return () => { + if (released) return; + released = true; + const current = this.entries.get(surfaceId); + if (current) current.activityRetain = Math.max(0, current.activityRetain - 1); + releaseCatalog(); + }; + } + + /** Surfaces whose catalog is loaded, for cross-device consumers. */ + loadedSurfaces(): DeviceSurfaceId[] { + return Array.from(this.entries.values()) + .filter(entry => entry.retainCount > 0 && entry.snapshot.status !== 'idle') + .map(entry => entry.surfaceId); + } + + /** Keep one workspace's sessions loaded while it is expanded. */ + retainSessions(surfaceId: DeviceSurfaceId, workspaceId: string): () => void { + const entry = this.entryFor(surfaceId); + const count = entry.workspaceRetain.get(workspaceId) ?? 0; + entry.workspaceRetain.set(workspaceId, count + 1); + const current = entry.snapshot.sessions[workspaceId]; + // A page the activity refresh already keeps current is not read again. + if (count === 0 && (!current || current.status === 'idle' || current.status === 'error')) { + void this.loadSessions(entry, workspaceId, current?.limit ?? DEVICE_CATALOG_SESSION_PAGE); + } + let released = false; + return () => { + if (released) return; + released = true; + const current = this.entries.get(surfaceId); + if (!current) return; + const remaining = (current.workspaceRetain.get(workspaceId) ?? 1) - 1; + if (remaining <= 0) current.workspaceRetain.delete(workspaceId); + else current.workspaceRetain.set(workspaceId, remaining); + }; + } + + /** Read at least `limit` top-level sessions (default: one more step). */ + loadMoreSessions(surfaceId: DeviceSurfaceId, workspaceId: string, limit?: number): Promise { + const entry = this.entries.get(surfaceId); + if (!entry) return Promise.resolve(); + const current = entry.snapshot.sessions[workspaceId] ?? EMPTY_SESSIONS; + const target = Math.min(limit ?? current.limit + DEVICE_CATALOG_SESSION_STEP, MAX_REFRESHED_SESSIONS); + if (target <= current.limit && current.status !== 'error') return Promise.resolve(); + return this.loadSessions(entry, workspaceId, target); + } + + /** + * Mark every loaded session of a device read, on its host. The catalog is + * updated at once (rows, device status, companion), then re-read. Returns + * the number of sessions acknowledged. + */ + async markAllRead(surfaceId: DeviceSurfaceId): Promise { + return this.acknowledgeSessions(surfaceId, null); + } + + /** Mark one loaded session of a device read on its host (companion dismiss). */ + async markSessionRead(surfaceId: DeviceSurfaceId, sessionId: string): Promise { + return (await this.acknowledgeSessions(surfaceId, sessionId)) > 0; + } + + private async acknowledgeSessions(surfaceId: DeviceSurfaceId, onlySessionId: string | null): Promise { + const entry = this.entries.get(surfaceId); + if (!entry || entry.snapshot.status !== 'ready') return 0; + const targets: Array<{ workspace: WorkspaceInfo; metadata: SessionMetadata }> = []; + for (const workspace of entry.snapshot.workspaces) { + const page = entry.snapshot.sessions[workspace.id]; + if (!page) continue; + for (const metadata of page.records) { + if (onlySessionId !== null && metadata.sessionId !== onlySessionId) continue; + if (metadata.unreadCompletion || metadata.needsUserAttention || page.activities[metadata.sessionId]?.unreadCompletion) { + // The receipt must name the host's own record of the unread result. + const receipt = page.activities[metadata.sessionId]?.unreadTurn + ?? (metadata.customMetadata as { unreadCompletionTarget?: unknown } | undefined)?.unreadCompletionTarget + ?? metadata.lastTurn; + targets.push({ workspace, metadata: receipt ? { + ...metadata, lastTurn: receipt as SessionMetadata['lastTurn'], + customMetadata: { ...(metadata.customMetadata ?? {}), unreadCompletionTarget: receipt }, + } as SessionMetadata : metadata }); + } + } + } + if (targets.length === 0) return 0; + const cleared = new Set(targets.map(target => target.metadata.sessionId)); + const sessions = Object.fromEntries(Object.entries(entry.snapshot.sessions).map(([workspaceId, page]) => [workspaceId, { + ...page, + records: page.records.map(record => (cleared.has(record.sessionId) + ? { ...record, unreadCompletion: undefined, needsUserAttention: undefined } : record)), + sessions: page.sessions.map(record => (cleared.has(record.sessionId) + ? { ...record, unreadCompletion: undefined, needsUserAttention: undefined } : record)), + activities: Object.fromEntries(Object.entries(page.activities).map(([sessionId, activity]) => [sessionId, + cleared.has(sessionId) ? { ...activity, unreadCompletion: undefined, unreadTurn: undefined } : activity])), + }])); + this.update(entry, { sessions }); + await this.negotiate(entry); + const results = await Promise.allSettled(targets.map(({ workspace, metadata }) => this.io.acknowledge( + entry.deviceId, this.workspaceRequest(entry, workspace), metadata as unknown as Record, + ))); + const failed = results.filter(result => result.status === 'rejected'); + if (failed.length > 0) log.warn('Some sessions could not be marked read on their device', { surfaceId, failed: failed.length }); + this.invalidate(surfaceId, 'sessions'); + return targets.length - failed.length; + } + + /** Explicit retry after a visible failure; bypasses the debounce. */ + retry(surfaceId: DeviceSurfaceId): Promise { + const entry = this.entries.get(surfaceId); + if (!entry) return Promise.resolve(); + if (!entry.snapshot.live) this.startStream(entry); + entry.pendingScope = 'all'; + return this.invalidateNow(entry); + } + + /** Coalesce a burst of host hints into one read. */ + invalidate(surfaceId: DeviceSurfaceId, scope: RefreshScope = 'all'): void { + const entry = this.entries.get(surfaceId); + if (!entry || entry.retainCount === 0 || entry.stopped) return; + // A cross-device open owns the relay link until it settles, and a hidden + // window shows nothing: navigation keeps what it has and catches up once, + // right after. + if (getDeviceOpenIntent() || (typeof document !== 'undefined' && document.visibilityState === 'hidden')) { + this.deferred.set(surfaceId, mergeScope(this.deferred.get(surfaceId), scope)); + return; + } + entry.pendingScope = mergeScope(entry.debounceTimer !== null ? entry.pendingScope : undefined, scope); + if (entry.debounceTimer !== null) clearTimeout(entry.debounceTimer); + entry.debounceTimer = setTimeout(() => { + entry.debounceTimer = null; + void this.invalidateNow(entry); + }, DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + } + + /** + * Account change: no catalog survives another identity. Sections that are + * still open read their device again under the new account. + */ + reset(): void { + this.generation += 1; + const previous = Array.from(this.entries.values()); + this.entries.clear(); + for (const old of previous) { + this.stop(old); + if (old.retainCount > 0) { + const entry = this.entryFor(old.surfaceId); + entry.retainCount = old.retainCount; + entry.activityRetain = old.activityRetain; + for (const [workspaceId, count] of old.workspaceRetain) entry.workspaceRetain.set(workspaceId, count); + this.startStream(entry); + void this.invalidateNow(entry); + } + this.notify(old.surfaceId); + } + } + + private entryFor(surfaceId: DeviceSurfaceId): Entry { + const existing = this.entries.get(surfaceId); + if (existing) return existing; + const entry: Entry = { + surfaceId, + deviceId: isLocalSurface(surfaceId) ? null : surfaceId, + generation: this.generation, + snapshot: EMPTY_SNAPSHOT, + retainCount: 0, + releaseTimer: null, + debounceTimer: null, + workspaceRetain: new Map(), + activityRetain: 0, + sync: new InvalidationSync(() => this.read(entry)), + workspaceIdReferences: isLocalSurface(surfaceId) ? true : null, + negotiation: null, + sessionReads: new Map(), + streamSubscriptionId: null, + streamPending: false, + stopped: false, + pendingScope: 'all', + workspacesRevision: undefined, + }; + this.entries.set(surfaceId, entry); + return entry; + } + + private release(entry: Entry): void { + entry.retainCount = Math.max(0, entry.retainCount - 1); + if (entry.retainCount > 0 || entry.stopped) return; + if (entry.debounceTimer !== null) { + clearTimeout(entry.debounceTimer); + entry.debounceTimer = null; + } + entry.releaseTimer = setTimeout(() => { + entry.releaseTimer = null; + if (entry.retainCount === 0) this.stopStream(entry); + }, DEVICE_CATALOG_RELEASE_GRACE_MS); + } + + private isCurrent(entry: Entry): boolean { + return !entry.stopped && entry.generation === this.generation && this.entries.get(entry.surfaceId) === entry; + } + + private update(entry: Entry, patch: Partial): void { + if (!this.isCurrent(entry)) return; + entry.snapshot = { ...entry.snapshot, ...patch }; + this.notify(entry.surfaceId); + } + + private updateSessions(entry: Entry, workspaceId: string, patch: Partial): void { + if (!this.isCurrent(entry)) return; + const current = entry.snapshot.sessions[workspaceId] ?? EMPTY_SESSIONS; + this.update(entry, { sessions: { ...entry.snapshot.sessions, [workspaceId]: { ...current, ...patch } } }); + } + + /** + * One notification per surface per task: a read updates status, workspaces + * and every session page in quick succession, and subscribers (rows, device + * status, companion, seeding) only need the settled result. + */ + private notify(surfaceId: DeviceSurfaceId): void { + const first = this.notifyQueue.size === 0; + this.notifyQueue.add(surfaceId); + if (!first) return; + queueMicrotask(() => { + const surfaces = Array.from(this.notifyQueue); + this.notifyQueue.clear(); + for (const changed of surfaces) { + for (const listener of Array.from(this.listeners)) { + try { + listener(changed); + } catch (error) { + log.warn('Device catalog listener threw', error); + } + } + } + }); + } + + private invalidateNow(entry: Entry): Promise { + if (!this.isCurrent(entry)) return Promise.resolve(); + return entry.sync.invalidate(); + } + + private async read(entry: Entry): Promise { + if (!this.isCurrent(entry)) return; + const scope = entry.pendingScope; + entry.pendingScope = 'sessions'; + // A session-only change (turn started/ended, title) leaves the workspace + // list as it is: refresh the session pages without re-reading it. + if (scope === 'sessions' && entry.snapshot.status === 'ready' && !entry.snapshot.error) { + const workspaces = entry.snapshot.workspaces.map(workspace => workspace.id); + const targets = entry.activityRetain > 0 ? workspaces + : Array.from(entry.workspaceRetain.keys()).filter(workspaceId => workspaces.includes(workspaceId)); + await this.loadSessionPages(entry, targets, false); + return; + } + this.update(entry, { status: entry.snapshot.status === 'ready' ? 'ready' : 'loading', error: undefined }); + try { + await this.negotiate(entry); + const raw = await this.io.invoke(entry.deviceId, 'get_opened_workspaces', { request: {} }); + if (!this.isCurrent(entry)) return; + // The same display order the live list of this device uses. + const workspaces = stableWorkspaceOrder(entry.surfaceId, + upgradeLegacyWorktreeReferences(Array.isArray(raw) ? raw : []).map(mapWorkspaceInfo)); + const opened = new Set(workspaces.map(workspace => workspace.id)); + const sessions = Object.fromEntries(Object.entries(entry.snapshot.sessions) + .filter(([workspaceId]) => opened.has(workspaceId))); + this.update(entry, { status: 'ready', workspaces, sessions, error: undefined }); + const targets = entry.activityRetain > 0 + ? workspaces.map(workspace => workspace.id) + : Array.from(entry.workspaceRetain.keys()).filter(workspaceId => opened.has(workspaceId)); + await this.loadSessionPages(entry, targets, false); + } catch (error) { + if (!this.isCurrent(entry)) return; + log.warn('Failed to read device workspace catalog', { surfaceId: entry.surfaceId, error }); + // Keep what was rendered; the section offers a retry. + this.update(entry, { status: entry.snapshot.workspaces.length > 0 ? 'ready' : 'error', error: errorText(error) }); + } + } + + /** Learn whether the host addresses sessions by workspace id. */ + private negotiate(entry: Entry): Promise { + if (entry.workspaceIdReferences !== null) return Promise.resolve(); + entry.negotiation ??= this.io.invoke<{ capabilities?: Record } | undefined>( + entry.deviceId, 'peer_mode_ping', {}, + ).then(ping => { + entry.workspaceIdReferences = ping?.capabilities?.workspace_id_references_v1 === true; + }).finally(() => { entry.negotiation = null; }); + return entry.negotiation; + } + + private workspaceRequest(entry: Entry, workspace: WorkspaceInfo): Record { + if (entry.workspaceIdReferences) return { workspace_id: workspace.id }; + // Pre-ID hosts address sessions by their own root; never a controller path. + const remote = workspace.workspaceKind === 'remote'; + return { + workspace_path: workspace.rootPath, + remote_connection_id: remote ? workspace.connectionId : undefined, + remote_ssh_host: remote ? workspace.sshHost : undefined, + }; + } + + /** Refresh several workspaces with bounded concurrency. */ + private async loadSessionPages(entry: Entry, workspaceIds: readonly string[], onlyMissing: boolean): Promise { + const queue = workspaceIds.filter(workspaceId => !onlyMissing + || this.getSessions(entry.surfaceId, workspaceId).status === 'idle'); + const worker = async () => { + for (let workspaceId = queue.shift(); workspaceId !== undefined; workspaceId = queue.shift()) { + if (!this.isCurrent(entry)) return; + await this.loadSessions(entry, workspaceId, this.getSessions(entry.surfaceId, workspaceId).limit); + } + }; + await Promise.all(Array.from({ length: Math.min(SESSION_READ_CONCURRENCY, queue.length) }, worker)); + } + + private async loadSessions(entry: Entry, workspaceId: string, limit: number): Promise { + if (!this.isCurrent(entry)) return; + const workspace = entry.snapshot.workspaces.find(candidate => candidate.id === workspaceId); + if (!workspace) return; + const read = (entry.sessionReads.get(workspaceId) ?? 0) + 1; + entry.sessionReads.set(workspaceId, read); + const latest = () => this.isCurrent(entry) && entry.sessionReads.get(workspaceId) === read; + this.updateSessions(entry, workspaceId, { status: 'loading', limit, error: undefined }); + try { + await this.negotiate(entry); + const sessions: SessionMetadata[] = []; + const records: SessionMetadata[] = []; + const activities: Record = {}; + let cursor: string | undefined; + let hasMore = false; + let total: number | null = null; + for (let page = 0; page < MAX_PAGES_PER_READ; page += 1) { + const result = await this.io.invoke(entry.deviceId, 'list_persisted_sessions_page', { + request: { + ...this.workspaceRequest(entry, workspace), + limit, + ...(cursor ? { cursor } : {}), + }, + }); + if (!latest()) return; + for (const session of result?.sessions ?? []) { + if (session.status === 'archived') continue; + records.push(session); + if (isTopLevel(session)) sessions.push(session); + } + for (const activity of result?.activities ?? []) activities[activity.sessionId] = activity; + hasMore = result?.hasMore === true; + cursor = result?.nextCursor; + total = typeof result?.totalTopLevelCount === 'number' ? result.totalTopLevelCount : null; + if (!hasMore || !cursor || sessions.length >= limit) break; + } + this.updateSessions(entry, workspaceId, { + status: 'ready', + sessions: sessions.slice(0, limit), + records, + activities, + hasMore: hasMore || sessions.length > limit, + total, + limit, + error: undefined, + }); + } catch (error) { + if (!latest()) return; + log.warn('Failed to read device sessions', { surfaceId: entry.surfaceId, workspaceId, error }); + // A host that refused the request may have been replaced by another + // build (upgrade or downgrade): ask again how it addresses sessions. + if ((error as Error | undefined)?.name === 'DeviceCatalogCommandError' && !isLocalSurface(entry.surfaceId)) { + entry.workspaceIdReferences = null; + } + const current = this.getSessions(entry.surfaceId, workspaceId); + this.updateSessions(entry, workspaceId, { + status: current.sessions.length > 0 ? 'ready' : 'error', + error: errorText(error), + }); + } + } + + private startStream(entry: Entry): void { + if (entry.deviceId === null || entry.streamSubscriptionId !== null || entry.streamPending) return; + entry.streamPending = true; + const deviceId = entry.deviceId; + void this.io.subscribeCatalog(deviceId).then(subscriptionId => { + entry.streamPending = false; + if (!this.isCurrent(entry) || entry.retainCount === 0) { + void this.io.unsubscribeCatalog(subscriptionId).catch(() => undefined); + return; + } + entry.streamSubscriptionId = subscriptionId; + this.update(entry, { live: true }); + }, error => { + entry.streamPending = false; + // An older host has no catalog stream; visibility refresh covers it. + log.info('Device catalog stream unavailable', { deviceId, error: errorText(error) }); + this.update(entry, { live: false }); + }); + } + + private stopStream(entry: Entry): void { + const subscriptionId = entry.streamSubscriptionId; + entry.streamSubscriptionId = null; + if (subscriptionId) { + void this.io.unsubscribeCatalog(subscriptionId).catch(error => { + log.warn('Device catalog unsubscribe failed', { surfaceId: entry.surfaceId, error }); + }); + } + if (entry.snapshot.live) this.update(entry, { live: false }); + } + + private stop(entry: Entry): void { + this.stopStream(entry); + entry.stopped = true; + entry.sync.stop(); + if (entry.debounceTimer !== null) clearTimeout(entry.debounceTimer); + if (entry.releaseTimer !== null) clearTimeout(entry.releaseTimer); + entry.debounceTimer = null; + entry.releaseTimer = null; + } + + private installEvents(): void { + if (this.teardownEvents) return; + const disposers: Array<() => void> = []; + // Observers only see events that have a native listener registered. + disposers.push(this.io.listen('host-catalog-changed', () => undefined)); + disposers.push(this.io.listen('account://login-state', () => this.reset())); + disposers.push(this.io.listen('account://session-sync-error', payload => { + const event = payload as { sessionId?: string; targetDeviceId?: string } | null; + if (event?.sessionId !== HOST_CATALOG_ID || !event.targetDeviceId) return; + const entry = this.entries.get(surfaceIdForDevice(event.targetDeviceId)); + if (!entry) return; + entry.streamSubscriptionId = null; + this.update(entry, { live: false }); + })); + disposers.push(observeSurfaceEvents((event, sourceDeviceId, payload) => { + if (!CATALOG_INVALIDATING_EVENTS.has(event)) return; + const surfaceId = surfaceIdForDevice(sourceDeviceId); + // The rendered device is drawn from its live container. + if (surfaceId === getActiveSurfaceId()) return; + this.invalidate(surfaceId, this.hintScope(surfaceId, event, payload)); + })); + const flushDeferred = () => { + if (this.deferred.size === 0) return; + const pending = Array.from(this.deferred); + this.deferred.clear(); + for (const [surfaceId, scope] of pending) this.invalidate(surfaceId, scope); + }; + disposers.push(useDeviceOpenIntent.subscribe(state => { + if (!state.intent) flushDeferred(); + })); + // The device just left was not refreshed while rendered; read it again. + // The device now rendered is drawn from its live list and needs no read. + disposers.push(onSurfaceActivated(() => { + const rendered = getActiveSurfaceId(); + for (const entry of this.entries.values()) { + if (entry.surfaceId !== rendered) this.invalidate(entry.surfaceId); + } + })); + if (typeof document !== 'undefined') { + const onVisible = () => { + if (document.visibilityState !== 'visible') return; + flushDeferred(); + for (const entry of this.entries.values()) { + if (!entry.snapshot.live) this.invalidate(entry.surfaceId); + } + }; + document.addEventListener('visibilitychange', onVisible); + disposers.push(() => document.removeEventListener('visibilitychange', onVisible)); + } + // A host without the catalog stream sends no hints: re-read the devices + // shown from it now and then, only while the window is visible. + const poll = setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + const rendered = getActiveSurfaceId(); + for (const entry of this.entries.values()) { + if (!entry.snapshot.live && entry.surfaceId !== rendered && entry.snapshot.status === 'ready') { + this.invalidate(entry.surfaceId); + } + } + }, DEVICE_CATALOG_POLL_MS); + disposers.push(() => clearInterval(poll)); + this.teardownEvents = () => { + for (const dispose of disposers) dispose(); + }; + } + + /** + * The catalog stream hint names which revision moved: an unchanged + * workspaces revision means only session pages need a read. Lifecycle events + * are session facts; a workspace catalog event changes the list. + */ + private hintScope(surfaceId: DeviceSurfaceId, event: string, payload: unknown): RefreshScope { + if (event === 'workspace-catalog-changed') return 'all'; + if (event !== 'host-catalog-changed') return 'sessions'; + const entry = this.entries.get(surfaceId); + const revision = (payload as { workspacesRevision?: unknown } | null)?.workspacesRevision; + if (!entry || revision === undefined) return 'all'; + const changed = entry.workspacesRevision !== undefined && entry.workspacesRevision !== revision; + const first = entry.workspacesRevision === undefined; + entry.workspacesRevision = revision; + return changed || first ? 'all' : 'sessions'; + } + + /** Tests only. */ + dispose(): void { + this.generation += 1; + for (const entry of this.entries.values()) this.stop(entry); + this.entries.clear(); + this.teardownEvents?.(); + this.teardownEvents = null; + this.listeners.clear(); + } +} + +/** Window-wide: sections mount and unmount, the catalog cache does not. */ +export const deviceWorkspaceCatalog = new DeviceWorkspaceCatalog(); diff --git a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts index 1db9d9f1e4..a698b02df0 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts @@ -1,6 +1,7 @@ /** Production composition for the window-wide device-surface controller. */ import type { ITransportAdapter } from '@/infrastructure/api/adapters/base'; +import { getDeviceOpenIntent } from './deviceOpenIntent'; import { createTransportAdapter, getTransportAdapter, @@ -149,6 +150,7 @@ function createDependencies(): PeerDeviceSurfaceControllerDependencies { }, resetProductSurface, reloadConfig: reloadConfigFromCurrentTransport, + deferConfigReload: () => getDeviceOpenIntent() !== null, rebootstrapWorkspaces: async (scope) => { await workspaceManager.reinitializeForPeerModeSwitch(); scope.assertCurrent('rebootstrap device surface workspaces'); diff --git a/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts new file mode 100644 index 0000000000..dc4a5ba62e --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isPeerReplyLost, peerReplyLostMessage } from './peerReplyLost'; + +describe('peerReplyLost', () => { + it('recognises current and legacy lost-reply errors only', () => { + expect(isPeerReplyLost(new Error('Relay connection closed before the reply arrived; delivery outcome is unknown'))).toBe(true); + expect(isPeerReplyLost('failed to receive ack')).toBe(true); + expect(isPeerReplyLost(new Error('Relay is disconnected; request was not submitted'))).toBe(true); + expect(isPeerReplyLost(new Error('RPC target unavailable'))).toBe(false); + expect(isPeerReplyLost(undefined)).toBe(false); + }); + + it('localizes a lost reply and leaves other errors to the caller', () => { + const t = (key: string) => key; + expect(peerReplyLostMessage(new Error('failed to receive ack'), t)).toBe('common:peerHost.connectionInterrupted'); + expect(peerReplyLostMessage(new Error('boom'), t)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts new file mode 100644 index 0000000000..fa6c4049fa --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts @@ -0,0 +1,22 @@ +/** + * A device request whose reply was lost because this window's Relay + * connection closed mid-flight (a reconnect or device switch). The request + * may or may not have run, so callers show a calm "try again" instead of the + * transport's own text. + */ + +// `realtime_client::RELAY_REPLY_LOST` keeps this prefix byte-stable; the +// second pattern is the socket library's text from builds before it existed. +const REPLY_LOST = /Relay connection closed before the reply arrived|failed to receive ack|Relay is disconnected/; + +export function isPeerReplyLost(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + return REPLY_LOST.test(message); +} + +type Translate = (key: string, options?: Record) => string; + +/** Localized copy for a lost reply, or `null` when `error` is something else. */ +export function peerReplyLostMessage(error: unknown, t: Translate): string | null { + return isPeerReplyLost(error) ? t('common:peerHost.connectionInterrupted') : null; +} diff --git a/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts new file mode 100644 index 0000000000..4a65b5a255 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import type { DeviceSurfaceId } from './deviceSurface'; +import { + deviceWorkspaceCatalog, + type DeviceCatalogSessions, + type DeviceCatalogSnapshot, +} from './deviceWorkspaceCatalog'; + +function useCatalogSubscription(surfaceId: DeviceSurfaceId) { + return useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); +} + +/** Read a device catalog; it is loaded only while `enabled` (the section is open). */ +export function useDeviceWorkspaceCatalog(surfaceId: DeviceSurfaceId, enabled: boolean): DeviceCatalogSnapshot { + useEffect(() => (enabled ? deviceWorkspaceCatalog.retain(surfaceId) : undefined), [surfaceId, enabled]); + const subscribe = useCatalogSubscription(surfaceId); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId), [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} + +/** Sessions of one catalog workspace, read only while it is expanded. */ +export function useDeviceCatalogSessions( + surfaceId: DeviceSurfaceId, + workspaceId: string, + enabled: boolean, +): DeviceCatalogSessions { + useEffect( + () => (enabled ? deviceWorkspaceCatalog.retainSessions(surfaceId, workspaceId) : undefined), + [surfaceId, workspaceId, enabled], + ); + const subscribe = useCatalogSubscription(surfaceId); + const read = useCallback( + () => deviceWorkspaceCatalog.getSessions(surfaceId, workspaceId), + [surfaceId, workspaceId], + ); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts new file mode 100644 index 0000000000..26db57001e --- /dev/null +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getWorkspaceDisplayOrderRevision, + noteWorkspaceActivated, + orderWorkspacesForDisplay, + resetWorkspaceDisplayOrdersForTest, + setWorkspaceDisplayOrder, + stableWorkspaceOrder, +} from './workspaceDisplayOrder'; + +const list = (...ids: string[]) => ids.map(id => ({ id })); +const ids = (items: Array<{ id: string }>) => items.map(item => item.id); + +describe('stableWorkspaceOrder', () => { + afterEach(resetWorkspaceDisplayOrdersForTest); + + it('keeps the shown order when an older host moves the active workspace to the front', () => { + expect(ids(stableWorkspaceOrder('peer', list('openbitfun', 'repos')))).toEqual(['openbitfun', 'repos']); + // Activating repos on the host reordered its list. + expect(ids(stableWorkspaceOrder('peer', list('repos', 'openbitfun')))).toEqual(['openbitfun', 'repos']); + }); + + it('places a new workspace where the host put it and drops closed ones', () => { + stableWorkspaceOrder('peer', list('a', 'b', 'c')); + expect(ids(stableWorkspaceOrder('peer', list('new', 'c', 'a')))).toEqual(['new', 'a', 'c']); + }); + + it('keeps devices independent and lets an explicit reorder replace the order', () => { + stableWorkspaceOrder('local', list('a', 'b')); + stableWorkspaceOrder('peer', list('b', 'a')); + setWorkspaceDisplayOrder('local', ['b', 'a']); + expect(ids(stableWorkspaceOrder('local', list('a', 'b')))).toEqual(['b', 'a']); + expect(ids(stableWorkspaceOrder('peer', list('a', 'b')))).toEqual(['b', 'a']); + }); + + it('adopts an order the host was arranged into on purpose', () => { + stableWorkspaceOrder('peer', list('a', 'b', 'c', 'd')); + // Another controller reordered the host: more than a move to the front. + expect(ids(stableWorkspaceOrder('peer', list('b', 'a', 'd', 'c')))).toEqual(['b', 'a', 'd', 'c']); + expect(ids(stableWorkspaceOrder('peer', list('c', 'b', 'a', 'd')))).toEqual(['b', 'a', 'd', 'c']); + }); + + it('keeps the shown order through several activations an older host collected while the device was rendered', () => { + // Live list and catalog of one device, an older host that moves the + // active workspace to the front. + stableWorkspaceOrder('peer', list('assistant', 'herdr', 'openbitfun')); + // Rendered: the live list activates two workspaces and keeps showing its + // order; the catalog is not re-read meanwhile. + noteWorkspaceActivated('peer', 'openbitfun'); + expect(ids(stableWorkspaceOrder('peer', list('assistant', 'herdr', 'openbitfun')))).toEqual(['assistant', 'herdr', 'openbitfun']); + noteWorkspaceActivated('peer', 'herdr'); + // Left: the catalog reads the host's drifted list. + expect(ids(stableWorkspaceOrder('peer', list('herdr', 'openbitfun', 'assistant')))).toEqual(['assistant', 'herdr', 'openbitfun']); + // Rendered again: the live list's next read agrees, so nothing flips back. + expect(ids(stableWorkspaceOrder('peer', list('herdr', 'openbitfun', 'assistant')))).toEqual(['assistant', 'herdr', 'openbitfun']); + }); + + it('judges a read against the host order seen last, not only the shown one', () => { + stableWorkspaceOrder('peer', list('a', 'b', 'c')); + // Another controller activated c, then b. + expect(ids(stableWorkspaceOrder('peer', list('c', 'a', 'b')))).toEqual(['a', 'b', 'c']); + expect(ids(stableWorkspaceOrder('peer', list('b', 'c', 'a')))).toEqual(['a', 'b', 'c']); + }); + + it('orders an earlier list by the shown order without changing it', () => { + stableWorkspaceOrder('peer', list('a', 'b')); + setWorkspaceDisplayOrder('peer', ['b', 'a']); + const revision = getWorkspaceDisplayOrderRevision(); + expect(ids(orderWorkspacesForDisplay('peer', list('a', 'new', 'b')))).toEqual(['new', 'b', 'a']); + expect(ids(orderWorkspacesForDisplay('other', list('a', 'b')))).toEqual(['a', 'b']); + expect(getWorkspaceDisplayOrderRevision()).toBe(revision); + expect(ids(stableWorkspaceOrder('peer', list('a', 'b')))).toEqual(['b', 'a']); + }); +}); diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts new file mode 100644 index 0000000000..6176e4e493 --- /dev/null +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts @@ -0,0 +1,130 @@ +/** + * One display order of opened workspaces per device surface. + * + * Hosts before this change move a workspace to the front of their opened list + * whenever it becomes active, so every re-read (a device switch, a catalog + * refresh, another controller's activation) could reorder the sidebar. The + * window keeps the order it last showed for each device and applies it to + * every host list, live or background, so both projections of a device agree + * and activation never moves a row. Workspaces new to the list keep the place + * the host gave them relative to their neighbours; an explicit reorder + * replaces the order. A host order that differs by more than one workspace + * moved to the front was arranged on purpose (a reorder on another + * controller), so the window adopts it. + * + * "One workspace moved to the front" is judged against the shown order and + * against the host's own last order. While a device is rendered its catalog is + * not re-read, so an older host can collect several activations between two + * reads; comparing only with the shown order took that drift for a deliberate + * arrangement, and the live list and the catalog then kept adopting each + * other's order on every switch. The host order is therefore remembered too, + * and activations this window asks for are applied to it the way an older + * host applies them (`noteWorkspaceActivated`). + */ + +const orders = new Map(); +/** The host's own order as last seen, with this window's activations applied. */ +const hostOrders = new Map(); +const listeners = new Set<() => void>(); +let revision = 0; + +const sameIds = (left: readonly string[], right: readonly string[]) => ( + left.length === right.length && left.every((id, index) => id === right[index]) +); + +export function setWorkspaceDisplayOrder(surfaceId: string, workspaceIds: readonly string[]): void { + const previous = orders.get(surfaceId); + orders.set(surfaceId, [...workspaceIds]); + if (previous && sameIds(previous, workspaceIds)) return; + revision += 1; + for (const listener of Array.from(listeners)) listener(); +} + +/** Changes whenever a surface's display order changes (for `useSyncExternalStore`). */ +export function getWorkspaceDisplayOrderRevision(): number { + return revision; +} + +export function subscribeWorkspaceDisplayOrder(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +/** + * This window activated `workspaceId` on the surface's host. An older host + * moves it to the front of its opened list; a newer one keeps its place, which + * still matches the shown order. + */ +export function noteWorkspaceActivated(surfaceId: string, workspaceId: string): void { + const host = hostOrders.get(surfaceId); + if (!host?.includes(workspaceId)) return; + hostOrders.set(surfaceId, [workspaceId, ...host.filter(id => id !== workspaceId)]); +} + +export function stableWorkspaceOrder(surfaceId: string, hostWorkspaces: readonly T[]): T[] { + const remembered = orders.get(surfaceId); + const hostIds = hostWorkspaces.map(workspace => workspace.id); + if (!remembered) { + setWorkspaceDisplayOrder(surfaceId, hostIds); + hostOrders.set(surfaceId, hostIds); + return [...hostWorkspaces]; + } + // Both sides restricted to the workspaces they share. + const common = (previous: readonly string[]) => [ + previous.filter(id => hostIds.includes(id)), hostIds.filter(id => previous.includes(id)), + ] as const; + const [shown, listed] = common(remembered); + const lastHost = hostOrders.get(surfaceId); + if (!isActivationReorder(shown, listed) && !(lastHost && isActivationReorder(...common(lastHost)))) { + setWorkspaceDisplayOrder(surfaceId, hostIds); + hostOrders.set(surfaceId, hostIds); + return [...hostWorkspaces]; + } + // A list already in the shown order says nothing about the host (the live + // list passes the order it shows on activation); keep the host order seen last. + if (!sameIds(shown, listed)) hostOrders.set(surfaceId, hostIds); + const result = mergeIntoOrder(remembered, hostWorkspaces); + setWorkspaceDisplayOrder(surfaceId, result.map(workspace => workspace.id)); + return result; +} + +/** + * `workspaces` in the surface's remembered order, without changing it. For + * lists read earlier (a cached catalog, the last live list) that must look the + * same as the list now shown for the device. + */ +export function orderWorkspacesForDisplay(surfaceId: string, workspaces: readonly T[]): T[] { + const remembered = orders.get(surfaceId); + return remembered ? mergeIntoOrder(remembered, workspaces) : [...workspaces]; +} + +/** `remembered` order of `hostWorkspaces`; new ones keep the host's place among their neighbours. */ +function mergeIntoOrder(remembered: readonly string[], hostWorkspaces: readonly T[]): T[] { + const byId = new Map(hostWorkspaces.map(workspace => [workspace.id, workspace])); + const result: T[] = remembered.flatMap(id => (byId.has(id) ? [byId.get(id)!] : [])); + const placed = new Set(result.map(workspace => workspace.id)); + hostWorkspaces.forEach((workspace, hostIndex) => { + if (placed.has(workspace.id)) return; + // Before every shown workspace the host lists after it, else at the end. + const after = new Set(hostWorkspaces.slice(hostIndex + 1).map(candidate => candidate.id)); + const first = result.findIndex(candidate => after.has(candidate.id)); + const index = first === -1 ? result.length : first; + result.splice(index, 0, workspace); + placed.add(workspace.id); + }); + return result; +} + +/** `host` equals `shown`, or is `shown` with one workspace moved to the front. */ +function isActivationReorder(shown: readonly string[], host: readonly string[]): boolean { + if (shown.length !== host.length) return false; + if (sameIds(shown, host)) return true; + const moved = host[0]; + const rest = shown.filter(id => id !== moved); + return rest.every((id, index) => id === host[index + 1]); +} + +export function resetWorkspaceDisplayOrdersForTest(): void { + orders.clear(); + hostOrders.clear(); +} diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts index c054a67506..07712b4cd9 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts @@ -404,6 +404,25 @@ describe('WorkspaceManager assistant closing and reopening', () => { expect(manager.getState().assistantWorkspaces).toHaveLength(1); }); + it('shows a switched workspace after the host switch alone, refreshing lists afterwards', async () => { + const first = { id: 'first', name: 'First', rootPath: '/test/first', workspaceKind: 'normal' }; + const second = { id: 'second', name: 'Second', rootPath: '/test/second', workspaceKind: 'normal' }; + globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({ + cleanupRemovedCount: 0, currentWorkspace: first, openedWorkspaces: [first, second], + recentWorkspaces: [], assistantWorkspaces: [], primaryAssistantWorkspaceId: null, + }); + const manager = await getFreshWorkspaceManager(); + await manager.initialize(); + let releaseLists!: (value: unknown) => void; + globalStateMocks.getOpenedWorkspaces.mockReturnValue(new Promise(resolve => { releaseLists = resolve; })); + globalStateMocks.setActiveWorkspace.mockResolvedValue(second); + await manager.setActiveWorkspace(second.id); + // Resolved while the list refresh is still in flight. + expect(manager.getState().activeWorkspaceId).toBe(second.id); + expect([...manager.getState().openedWorkspaces.keys()]).toEqual(['first', 'second']); + releaseLists([first, second]); + }); + it('waits for running sessions to be cancelled before closing and preserves the catalog', async () => { globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({ cleanupRemovedCount: 0, currentWorkspace: assistant, openedWorkspaces: [assistant], @@ -656,6 +675,33 @@ describe('WorkspaceManager device surface switching', () => { expect(manager.getState().currentWorkspace?.id).toBe('workspace-b'); }); + it('shows a device in the order its background catalog settled on, live and cached alike', async () => { + const workspace = (id: string) => ({ ...workspaceA, id, name: id, rootPath: `/${id}` }); + const [a, b, c] = [workspace('a'), workspace('b'), workspace('c')]; + globalStateMocks.initializeWorkspaceStartupState + .mockResolvedValueOnce({ ...startupSnapshot(a), openedWorkspaces: [a, b, c] }) + .mockResolvedValueOnce(startupSnapshot(workspaceB)); + const { manager, deviceSurface } = await getFreshWorkspaceHarness(); + const displayOrder = await import('./workspaceDisplayOrder'); + await manager.initialize(); + deviceSurface.activateSurface('peer-b'); + await manager.reinitializeForPeerModeSwitch(); + + // While another device is rendered, this device's catalog reads a list + // arranged on another controller. + expect(displayOrder.stableWorkspaceOrder(deviceSurface.LOCAL_SURFACE_ID, [c, b, a]).map(item => item.id)).toEqual(['c', 'b', 'a']); + // The background section's fallback and the live list returned to both + // draw that order; the live list's next host read keeps it. + expect(manager.getSurfaceOpenedWorkspaces(deviceSurface.LOCAL_SURFACE_ID).map(item => item.id)).toEqual(['c', 'b', 'a']); + deviceSurface.activateSurface(deviceSurface.LOCAL_SURFACE_ID); + expect([...manager.getState().openedWorkspaces.keys()]).toEqual(['c', 'b', 'a']); + globalStateMocks.setActiveWorkspace.mockResolvedValue(b); + globalStateMocks.getOpenedWorkspaces.mockResolvedValue([b, c, a]); + await manager.setActiveWorkspace(b.id); + await flushAsyncWork(); + expect([...manager.getState().openedWorkspaces.keys()]).toEqual(['c', 'b', 'a']); + }); + it('keeps a cached surface visible when its refresh fails', async () => { globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue( startupSnapshot(workspaceA), diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 69d6ebd947..92e9e795cf 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1,5 +1,11 @@ +import { + noteWorkspaceActivated, + orderWorkspacesForDisplay, + setWorkspaceDisplayOrder, + stableWorkspaceOrder, +} from './workspaceDisplayOrder'; import type { RemoteWorkspaceSnapshot, WorkspaceInfo, @@ -168,6 +174,14 @@ class WorkspaceManager { // The activation commit swaps transport before listeners run, so consumers // can safely render this surface's cached workspace snapshot immediately. onSurfaceActivated(scope => { + // While this device was in the background its catalog may have changed + // its display order; show the cached list in that order at once, so the + // hand-over from the catalog projection moves no row. + const cached = this.getOpenedWorkspacesList(); + const ordered = orderWorkspacesForDisplay(scope.surfaceId, cached); + if (ordered.some((workspace, index) => workspace !== cached[index])) { + this.state = { ...this.state, openedWorkspaces: this.buildOpenedWorkspaceMap(ordered) }; + } this.emit({ type: 'workspace:active-changed', workspace: this.state.currentWorkspace, @@ -183,6 +197,16 @@ class WorkspaceManager { }); } + /** Opened workspaces last loaded for a surface, rendered or not. */ + public getSurfaceOpenedWorkspaces(surfaceId: DeviceSurfaceId): WorkspaceInfo[] { + // In the order the device is shown now: a background catalog read may + // have changed it since this list was loaded. + return orderWorkspacesForDisplay( + surfaceId, + Array.from(this.surfaceContainers.get(surfaceId)?.state.openedWorkspaces.values() ?? []), + ); + } + private surfaceContainer(surfaceId: DeviceSurfaceId): WorkspaceSurfaceContainer { const existing = this.surfaceContainers.get(surfaceId); if (existing) { @@ -347,22 +371,15 @@ class WorkspaceManager { : workspace.workspaceKind !== 'assistant'; } + /** + * A host list in this surface's display order. The same order the device + * catalog applies, so the live list and the device's background projection + * never disagree (see `workspaceDisplayOrder`). Ordering by this window's + * previous list instead let the two projections adopt each other's order on + * every switch. + */ private preserveOpenedWorkspaceOrder(workspaces: WorkspaceInfo[]): WorkspaceInfo[] { - const currentOrder = Array.from(this.state.openedWorkspaces.keys()); - const nextWorkspaceMap = this.buildOpenedWorkspaceMap(workspaces); - const orderedWorkspaces = currentOrder - .map(workspaceId => nextWorkspaceMap.get(workspaceId)) - .filter((workspace): workspace is WorkspaceInfo => Boolean(workspace)); - - const existingIds = new Set(orderedWorkspaces.map(workspace => workspace.id)); - - for (const workspace of workspaces) { - if (!existingIds.has(workspace.id)) { - orderedWorkspaces.push(workspace); - } - } - - return orderedWorkspaces; + return stableWorkspaceOrder(getActiveSurfaceId(), workspaces); } private buildReorderedOpenedWorkspaceIds( @@ -447,7 +464,10 @@ class WorkspaceManager { error: string | null, event?: WorkspaceEvent ): void { - const openedWorkspaceMap = this.buildOpenedWorkspaceMap(openedWorkspaces); + // Every list a host returns is shown in this surface's one display order. + const openedWorkspaceMap = this.buildOpenedWorkspaceMap( + stableWorkspaceOrder(getActiveSurfaceId(), openedWorkspaces), + ); const resolvedCurrentWorkspace = currentWorkspace ? openedWorkspaceMap.get(currentWorkspace.id) ?? currentWorkspace @@ -1410,6 +1430,8 @@ class WorkspaceManager { } } + private activeWorkspaceSwitchSequence = 0; + public async setActiveWorkspace(workspaceId: string): Promise { const surface = this.captureSurface(); try { @@ -1430,23 +1452,40 @@ class WorkspaceManager { ? await globalStateAPI.openWorkspaceById(workspaceId) : await globalStateAPI.setActiveWorkspace(workspaceId); this.assertSurfaceUnchanged(surface, 'set active workspace'); - const [recentWorkspaces, openedWorkspaces] = await Promise.all([ - globalStateAPI.getRecentWorkspaces(), - globalStateAPI.getOpenedWorkspaces(), - ]); - this.assertSurfaceUnchanged(surface, 'set active workspace'); - const orderedOpenedWorkspaces = this.preserveOpenedWorkspaceOrder(openedWorkspaces); - + // An older host moved it to the front of its opened list; that is not + // an arrangement the display order should adopt. + noteWorkspaceActivated(surface.scope.surfaceId, workspace.id); + // The host has switched: show it now, from the lists this window holds. + // The recent and opened lists are refreshed afterwards, off the path the + // user waits on (opening a session in another workspace waits on this). + const knownOpened = this.getOpenedWorkspacesList(); + const opened = knownOpened.some(candidate => candidate.id === workspace.id) + ? knownOpened : [workspace, ...knownOpened]; + const switchSequence = ++this.activeWorkspaceSwitchSequence; this.updateWorkspaceState( workspace, - recentWorkspaces, - orderedOpenedWorkspaces, + this.state.recentWorkspaces, + this.preserveOpenedWorkspaceOrder(opened), false, null, { type: 'workspace:switched', workspace } ); - this.emit({ type: 'workspace:active-changed', workspace }); + void Promise.all([ + globalStateAPI.getRecentWorkspaces(), + globalStateAPI.getOpenedWorkspaces(), + ]).then(([recentWorkspaces, openedWorkspaces]) => { + // A newer switch (or another device) owns the state by now. + if (!this.isSurfaceUnchanged(surface) || switchSequence !== this.activeWorkspaceSwitchSequence) return; + this.updateWorkspaceState( + this.state.currentWorkspace, + recentWorkspaces, + this.preserveOpenedWorkspaceOrder(openedWorkspaces), + false, + null, + { type: 'workspace:recent-updated' }, + ); + }).catch(error => log.warn('Failed to refresh workspace lists after a switch', { workspaceId, error })); return workspace; } catch (error) { if (!this.isSurfaceUnchanged(surface)) { @@ -1502,6 +1541,7 @@ class WorkspaceManager { return; } + setWorkspaceDisplayOrder(getActiveSurfaceId(), reorderedOpenedWorkspaces.map(workspace => workspace.id)); this.updateWorkspaceState( previousCurrentWorkspace, previousRecentWorkspaces, @@ -1518,6 +1558,7 @@ class WorkspaceManager { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); + setWorkspaceDisplayOrder(getActiveSurfaceId(), previousOpenedWorkspaces.map(workspace => workspace.id)); const rollbackEventWorkspace = previousCurrentWorkspace ?? workspaceMap.get(sourceWorkspaceId) ?? previousOpenedWorkspaces[0] diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 597cb3f776..764cd4cecd 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -64,6 +64,25 @@ "remoteConnectRequiresWorkspace": "Open a workspace before using remote control." }, "nav": { + "devices": { + "thisDevice": "This device", + "current": "Shown in this window", + "expand": "Expand {{name}}", + "collapse": "Collapse {{name}}", + "hostDesktop": "Desktop", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "Couldn't load this device's workspaces", + "sessionsLoadFailed": "Couldn't load sessions", + "noSessions": "No sessions yet", + "sessionUnavailable": "This session is no longer on that device.", + "sessionActionFailed": "Couldn't do that on the other device. Try again.", + "connecting": "Connecting to {{name}}…" + }, "aria": { "navControl": "Navigation controls", "mainNav": "Main navigation" @@ -830,7 +849,6 @@ "currentHostHint": "Files and commands stay on the current work host.", "otherHostHint": "Connect to a remote host or container over SSH. Native WSL is only available on the current Windows work host.", "wslHint": "Next, select a Linux distribution on the current Windows work host.", - "foldersLockedHint": "Remove folders to switch.", "connectionChoice": "Connection setup", "availableOptions": "Available connections and environments", "refreshOptions": "Refresh available options", @@ -1748,7 +1766,6 @@ "panelView": "Panel View" }, "sceneBar": { - "recentSessions": "Recently opened sessions", "tabsLabel": "Scene tabs", "scrollPrevious": "Scroll tabs left", "scrollNext": "Scroll tabs right", @@ -2326,8 +2343,19 @@ "modifiedDragUnavailable": "Dragging with modifier keys is not supported in this preview." }, "newWorkspace": { + "localProjectTitle": "Project on this device", + "localProjectDescription": "Link existing folders or start a new project.", + "deviceProjectTitle": "Project on another device", + "deviceProjectDescription": "Create on another of your online devices.", + "createDeviceProject": "Create a project on another device", + "chooseDevice": "Choose a device", + "noOtherDevices": "No other devices are online. Sign in to the same account in OpenBitFun on another device to choose it here.", + "signInForDevices": "Sign in to create projects on your other online devices.", + "createProjectOn": "Create a project on {{name}}", + "deviceCurrent": "Shown now", + "connecting": "Connecting…", + "deviceError": "Couldn't connect to this device. Try again later.", "title": "Create", - "description": "Choose what you want to create.", "assistantTitle": "Personal assistant", "assistantDescription": "An ongoing collaborator with its own memory.", "projectTitle": "Project", @@ -2338,6 +2366,7 @@ "back": "Back to selection" }, "peerHost": { + "connectionInterrupted": "The connection to the device was interrupted. Try again.", "unavailable": "This feature is not available on that device.", "updateRequired": "The OpenBitFun version on that device does not support this feature. Try updating OpenBitFun on that device.", "withDetail": "{{message}} ({{detail}})" diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 5a6a5a8a4c..f179bc31ee 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -247,6 +247,8 @@ "tokenCapacity": "{{used}} / {{size}} Tokens", "loading": "Loading context usage…", "loadFailed": "Unable to load context usage. Close and reopen to try again.", + "unsupported": "Context usage is not available on this device.", + "updateRequired": "The OpenBitFun version on this device cannot show context usage yet. Update OpenBitFun on that device to see it.", "title": "Context composition", "label": "Context", "utilization": "Context usage", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index c8ba9d855a..6cbfbcb886 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -64,6 +64,25 @@ "remoteConnectRequiresWorkspace": "请先打开工作区再使用远程控制。" }, "nav": { + "devices": { + "thisDevice": "本机", + "current": "当前窗口正在显示", + "expand": "展开 {{name}}", + "collapse": "收起 {{name}}", + "hostDesktop": "桌面版", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "无法读取该设备的工作区", + "sessionsLoadFailed": "无法读取会话", + "noSessions": "暂无会话", + "sessionUnavailable": "该设备上已找不到这个会话。", + "sessionActionFailed": "无法在另一台设备上完成此操作,请重试。", + "connecting": "正在连接 {{name}}…" + }, "aria": { "navControl": "导航控制", "mainNav": "主导航" @@ -830,7 +849,6 @@ "currentHostHint": "文件和命令均位于当前工作主机。", "otherHostHint": "通过 SSH 连接远程主机或容器。原生 WSL 仅支持当前 Windows 工作主机。", "wslHint": "下一步选择当前 Windows 工作主机上的 Linux 发行版。", - "foldersLockedHint": "移除文件夹后可切换。", "connectionChoice": "连接配置", "availableOptions": "可用连接与环境", "refreshOptions": "刷新可用选项", @@ -1748,7 +1766,6 @@ "panelView": "面板视图" }, "sceneBar": { - "recentSessions": "最近打开的会话", "tabsLabel": "场景标签页", "scrollPrevious": "向左滚动标签页", "scrollNext": "向右滚动标签页", @@ -2326,8 +2343,19 @@ "modifiedDragUnavailable": "共享预览暂不支持同时按住修饰键拖动。" }, "newWorkspace": { + "localProjectTitle": "本机工程", + "localProjectDescription": "关联已有文件夹,或新建工程。", + "deviceProjectTitle": "其他设备上的工程", + "deviceProjectDescription": "在你的其他在线设备上创建。", + "createDeviceProject": "在其他设备上创建工程", + "chooseDevice": "选择设备", + "noOtherDevices": "暂无其他在线设备。在其他设备上登录同一账号并打开 OpenBitFun 后,即可在这里选择。", + "signInForDevices": "登录账号后,可在同账号的其他在线设备上创建工程。", + "createProjectOn": "在 {{name}} 上创建工程", + "deviceCurrent": "当前显示", + "connecting": "正在连接…", + "deviceError": "无法连接该设备,请稍后重试。", "title": "创建", - "description": "选择要创建的内容。", "assistantTitle": "个人助理", "assistantDescription": "与你长期协作,拥有独立记忆。", "projectTitle": "工程", @@ -2338,6 +2366,7 @@ "back": "返回选择" }, "peerHost": { + "connectionInterrupted": "与设备的连接中断了,请重试。", "unavailable": "此功能在该设备上不可用。", "updateRequired": "该设备上的 OpenBitFun 版本不支持此功能。请尝试在该设备上更新 OpenBitFun。", "withDetail": "{{message}}({{detail}})" diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 85cb8532a8..0211fc3c95 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -247,6 +247,8 @@ "tokenCapacity": "{{used}} / {{size}} Tokens", "loading": "正在加载上下文用量…", "loadFailed": "无法加载上下文用量,请收起后重新打开。", + "unsupported": "该设备暂不支持查看上下文用量。", + "updateRequired": "该设备上的 OpenBitFun 版本暂不支持查看上下文用量,更新该设备上的 OpenBitFun 后即可查看。", "title": "上下文构成", "label": "上下文", "utilization": "使用占比", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 08e48d5dcf..607835a9db 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -64,6 +64,25 @@ "remoteConnectRequiresWorkspace": "請先開啟工作區再使用遠端控制。" }, "nav": { + "devices": { + "thisDevice": "本機", + "current": "目前視窗正在顯示", + "expand": "展開 {{name}}", + "collapse": "收合 {{name}}", + "hostDesktop": "桌面版", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "無法讀取該裝置的工作區", + "sessionsLoadFailed": "無法讀取會話", + "noSessions": "尚無會話", + "sessionUnavailable": "該裝置上已找不到這個會話。", + "sessionActionFailed": "無法在另一台裝置上完成此操作,請重試。", + "connecting": "正在連線 {{name}}…" + }, "aria": { "navControl": "導航控制", "mainNav": "主導航" @@ -830,7 +849,6 @@ "currentHostHint": "檔案和命令均位於目前工作主機。", "otherHostHint": "透過 SSH 連線遠端主機或容器。原生 WSL 僅支援目前 Windows 工作主機。", "wslHint": "下一步選擇目前 Windows 工作主機上的 Linux 發行版。", - "foldersLockedHint": "移除資料夾後可切換。", "connectionChoice": "連線設定", "availableOptions": "可用連線與環境", "refreshOptions": "重新整理可用選項", @@ -1748,7 +1766,6 @@ "panelView": "面板視圖" }, "sceneBar": { - "recentSessions": "最近開啟的工作階段", "tabsLabel": "場景標籤頁", "scrollPrevious": "向左捲動標籤頁", "scrollNext": "向右捲動標籤頁", @@ -2326,8 +2343,19 @@ "modifiedDragUnavailable": "共享預覽暫不支援同時按住修飾鍵拖曳。" }, "newWorkspace": { + "localProjectTitle": "本機專案", + "localProjectDescription": "關聯既有資料夾,或新建工程。", + "deviceProjectTitle": "其他裝置上的專案", + "deviceProjectDescription": "在你的其他線上裝置上建立。", + "createDeviceProject": "在其他裝置上建立專案", + "chooseDevice": "選擇裝置", + "noOtherDevices": "暫無其他線上裝置。在其他裝置上登入同一帳號並開啟 OpenBitFun 後,即可在這裡選擇。", + "signInForDevices": "登入帳號後,可在同帳號的其他線上裝置上建立專案。", + "createProjectOn": "在 {{name}} 上建立專案", + "deviceCurrent": "目前顯示", + "connecting": "正在連線…", + "deviceError": "無法連線到該裝置,請稍後重試。", "title": "建立", - "description": "選擇要建立的內容。", "assistantTitle": "個人助理", "assistantDescription": "與你長期協作,擁有獨立記憶。", "projectTitle": "專案", @@ -2338,6 +2366,7 @@ "back": "返回選擇" }, "peerHost": { + "connectionInterrupted": "與裝置的連線中斷了,請重試。", "unavailable": "此功能在該裝置上無法使用。", "updateRequired": "該裝置上的 OpenBitFun 版本不支援此功能。請嘗試在該裝置上更新 OpenBitFun。", "withDetail": "{{message}}({{detail}})" diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 404eadf28f..4fa982256c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -247,6 +247,8 @@ "tokenCapacity": "{{used}} / {{size}} Tokens", "loading": "正在載入上下文用量…", "loadFailed": "無法載入上下文用量,請收合後重新開啟。", + "unsupported": "此裝置暫不支援檢視上下文用量。", + "updateRequired": "此裝置上的 OpenBitFun 版本暫不支援檢視上下文用量,更新該裝置上的 OpenBitFun 後即可檢視。", "title": "上下文組成", "label": "上下文", "utilization": "使用占比", diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 0277d757c4..8ec29c685c 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -359,7 +359,7 @@ function mapWorkspaceWorktree( }; } -function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { +export function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { return { id: workspace.id, name: workspace.name,