Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
4076d9f
fix(workspace): keep the opened workspace order when one activates
bobleer Nov 4, 2026
7a221ab
feat(web-ui): read other devices' workspace and session catalogs
bobleer Nov 4, 2026
3d4bbea
feat(web-ui): list every device's workspaces and sessions in the sidebar
bobleer Nov 4, 2026
0e0d436
feat(web-ui): create projects on another device
bobleer Nov 4, 2026
81bc2d5
feat(web-ui): show every device's sessions in the Agent companion
bobleer Nov 4, 2026
47427c4
docs(peer-device): describe the sidebar device catalog
bobleer Nov 4, 2026
a0b33b0
fix(web-ui): forget remembered session totals when the device catalog…
bobleer Nov 4, 2026
5328641
fix(web-ui): keep the workspace label visible on hover for rows witho…
bobleer Nov 4, 2026
e748481
fix(web-ui): mark sessions of every device read from the session menu
bobleer Nov 4, 2026
a2ebd08
fix(web-ui): stop companion bubble glows from being cut at the column…
bobleer Nov 4, 2026
f595397
style(web-ui): light companion bubbles from the top left
bobleer Nov 4, 2026
72026ea
style(web-ui): let the companion bubble shadow spread into the space …
bobleer Nov 4, 2026
03c2c1c
fix(web-ui): offer every device the same switch action in the device …
bobleer Nov 4, 2026
ce78655
style(web-ui): drop the tooltip on the device card's action button
bobleer Nov 4, 2026
b5adf09
fix(web-ui): refresh a populated flat session list without a loading row
bobleer Nov 4, 2026
409cfd3
perf(web-ui): give a cross-device open the link before background work
bobleer Nov 4, 2026
d8305aa
fix(web-ui): persist read receipts that the host can match
bobleer Nov 4, 2026
fc9e0cc
fix(web-ui): platform tags in the device picker, no WSL noise on host…
bobleer Nov 4, 2026
c3fe2c3
fix(web-ui): keep background device lists and opens consistent
bobleer Nov 4, 2026
1c540aa
fix(core): declare the workspace recency iterator mutable
bobleer Nov 4, 2026
06e1a12
feat(web-ui): one top tab per session, kept for every device
bobleer Nov 4, 2026
3b3375f
perf(web-ui): fetch the clicked session first on a cross-device open
bobleer Nov 4, 2026
1942287
style(web-ui): show a session tab's device as a small capsule
bobleer Nov 4, 2026
3956b57
style(web-ui): quieter device label on session tabs that scrolls with…
bobleer Nov 4, 2026
dc45394
style(web-ui): drop the glyph from a session tab's device label
bobleer Nov 4, 2026
b89388d
feat(web-ui): rename a device from its card; home mark takes the acti…
bobleer Nov 4, 2026
87d029f
style(web-ui): edit a device name in place in the card's own type
bobleer Nov 4, 2026
693525d
feat(web-ui): devices dialog in the device card's language, grown fro…
bobleer Nov 4, 2026
59e27ed
style(web-ui): no outline rings; devices dialog as lit glass; contain…
bobleer Nov 4, 2026
8446b5e
style(web-ui): one neutral palette and calmer rows in the devices dialog
bobleer Nov 4, 2026
87bd0cb
perf: open a device session from a small first page; smoother waits
bobleer Nov 4, 2026
ced2a86
feat(web-ui): crossfade scene switches
bobleer Nov 4, 2026
7ed1762
perf(web-ui): one host round trip to switch workspace; warm scenes at…
bobleer Nov 4, 2026
0de96a0
fix(web-ui): keep the composer's layout while a session loads or opens
bobleer Nov 4, 2026
c5cd860
style(web-ui): devices dialog in the device card's atmosphere
bobleer Nov 4, 2026
e200eb2
perf(web-ui): open a local session without waiting on choreography
bobleer Nov 4, 2026
2689317
perf(web-ui): read history alongside the workspace switch; keep evict…
bobleer Nov 4, 2026
7527e2e
style(web-ui): plain chevrons on the devices overview rows
bobleer Nov 4, 2026
1b3f3e9
style(web-ui): group scene tabs by device and name each device once
bobleer Nov 4, 2026
619fd3e
style(web-ui): quieter create dialog; reachable close on the last sce…
bobleer Nov 4, 2026
3d5ed57
style(web-ui): silver light for this device and idle devices
bobleer Nov 4, 2026
7d35402
fix(web-ui): keep the last scene tab's close action clear of the scro…
bobleer Nov 4, 2026
9a05508
fix(web-ui): keep the session goal action steady when the + menu opens
bobleer Nov 4, 2026
76cfec4
fix(remote-connect): report a lost relay reply instead of 'failed to …
bobleer Nov 4, 2026
f221975
fix(remote-connect): start history pages at a turn boundary
bobleer Nov 4, 2026
dcf7097
perf(web-ui): show progress at once when creating a session, with few…
bobleer Nov 4, 2026
fffdf13
feat(web-ui): session menu on other devices' sidebar rows
bobleer Nov 4, 2026
b82100d
feat(cli): serve session usage reports to peer controllers
bobleer Nov 4, 2026
b485cf4
Merge remote-tracking branch 'upstream/main' into bob/sidebar-device-…
bobleer Nov 4, 2026
fa7d3a3
fix(web-ui): hold the transcript still when older history lands at th…
bobleer Nov 4, 2026
51a1294
fix(web-ui): open the add menu whole, with its additional modes
bobleer Nov 4, 2026
9eb6e06
fix(web-ui): keep a device's sidebar rows and order steady across swi…
bobleer Nov 4, 2026
9f05a44
test(cli): tolerate a delayed owner-lock release in the daemon lease …
bobleer Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/architecture/peer-device-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 13 additions & 1 deletion src/apps/cli/src/daemon/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src/apps/cli/src/peer_host/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
105 changes: 103 additions & 2 deletions src/apps/cli/src/peer_host/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Value, String> {
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<openbitfun_core_types::UsageContextCategory>,
}

/// 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<UsageReportOptions, String> {
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}")
}
Expand Down Expand Up @@ -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,
Expand Down
68 changes: 63 additions & 5 deletions src/crates/assembly/core/src/service/workspace/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,9 +1471,16 @@ impl WorkspaceManager {
}

fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option<String> {
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())
Expand All @@ -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());
}
Expand Down Expand Up @@ -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::{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"digest": "fnv1a64:ad57aa872484f2a8",
"digest": "fnv1a64:ff25ac8314514c25",
"retiredCommandPrefixes": [
{
"prefix": "lsp_",
Expand Down Expand Up @@ -3377,8 +3377,7 @@
"kind": "proxied"
},
"cliPeer": {
"kind": "unsupported",
"reason": "the CLI peer host has no handler for this command"
"kind": "handled"
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading