Skip to content

feat: show and act on every device's workspaces and sessions in the sidebar - #3725

Merged
bobleer merged 53 commits into
GCWing:mainfrom
bobleer:bob/sidebar-device-workspaces
Nov 4, 2026
Merged

bobleer merged 53 commits into
GCWing:mainfrom
bobleer:bob/sidebar-device-workspaces

Conversation

@bobleer

@bobleer bobleer commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Lists the workspaces and sessions of every online same-account device in the sidebar, in place, and lets users act on them as if they were local. Acting on another device renders it through the existing Peer Device Mode switch first, so capability gates, permission mailboxes and surface fences are unchanged.

  • Device catalog (infrastructure/peer-device/deviceWorkspaceCatalog.ts): lazily retained, read-only reads of get_opened_workspaces / list_persisted_sessions_page over raw HostInvoke; kept fresh by debounced @host/catalog hints (session-only hints skip the workspace list; held while the window is hidden or a cross-device open runs). No polling.
  • Background projection: catalog pages seed each device's own FlowChat container through the shared metadata projection, so other devices draw with the live list's hierarchy, ordering, filters, disclosure and row marks, and a switch finds its sessions already present.
  • Stable layout across switches: per-device workspace display order, remembered session totals, held session tab, and a workbench curtain for other switching actions. Lists show 3 sessions and add 5 per "show more".
  • Create dialog: personal assistant / project on this device / project on another device.
  • Agent companion: lists running, waiting and unread sessions of every device.
  • Host fix (workspace/manager.rs): activating an already opened workspace no longer moves it to the front of the opened list (it reordered the sidebar and overwrote user ordering).

Commits are split by layer: host fix, catalog/state, sidebar UI, create dialog, companion, docs.

Remote scenarios

  • Peer Device Mode: the feature itself. Catalog reads are read-only and registry-gated on the host; every mutation goes through the rendered surface.
  • Remote workspace: SSH workspaces on another device are listed with their remote badge; pre-ID hosts are addressed by their own root path, negotiated via workspace_id_references_v1.
  • Remote control / Detached Dispatch: unchanged.

Verified with unit and component tests only; not exercised on real multiple devices by the author.

Upgrade compatibility

  • Older hosts without host_stream_v1 fall back to visibility refresh; older CLI hosts (no load_persisted_session_metadata) open sessions from the catalog record.
  • Older hosts that still reorder on activation are masked by the per-device display order.
  • New persisted UI state is one localStorage key (openbitfun.sidebar-device-sections.v1, device section disclosure); workspace disclosure and totals stay in memory and reset with the account.

Test plan

  • tsc --noEmit, eslint . (0 errors)
  • Web UI vitest: 1177 files / 13571 tests
  • cargo test -p openbitfun-core --features workspace-runtime --lib service::workspace
  • i18n:audit, theme:color-audit:all, check:repo-hygiene, check:core-boundaries
  • Manual multi-device run (two signed-in hosts, one CLI)

Activating an already opened workspace moved it to the front of the host's
opened list, so every re-read reordered the sidebar and overwrote the order
users arranged. Newly opened workspaces still go first; recency stays in
the recent list.
Add a lazily retained, read-only catalog of every online same-account host
(get_opened_workspaces and list_persisted_sessions_page over raw HostInvoke
reads), kept current by debounced @host/catalog invalidations: session-only
hints skip the workspace list, refreshes are held while the window is
hidden or a cross-device open runs, and notifications are coalesced.

Catalog pages seed each background device's FlowChat container through the
shared metadata projection; seeds are promoted before any open and never
replace a loaded session. A per-device display order keeps workspaces in
place across hosts that still reorder on activation, and remembered totals
keep session counts stable while readers restart. A DeviceOpenIntent
describes a running cross-device open for the other owners.
The session list groups workspaces by device in a fixed order. The rendered
device keeps its live list; other devices draw from their seeded containers
with the live list's hierarchy, ordering, filters, disclosure and row marks,
and offer the same workspace actions. Acting on another device renders it
first: the clicked row and the session tab are held through the switch, and
a curtain covers other switching actions so the workbench never flashes.

Lists show three sessions and add five per "show more", with one step read
ahead; collapsing a workspace starts it over. Device headers name the
platform or CLI.
The create dialog offers a personal assistant, a project on this device, or
a project on another online device. Choosing a device renders it before the
same project form opens, so folders, recent workspaces and sources belong
to that device. The assistant is always created on this machine.
The companion lists running, waiting and unread sessions of background
devices next to the rendered device's, labelled with their device, and
emits only changed payloads. Opening one renders its device first with the
same held session tab as the sidebar.
Document catalog reads, freshness, background seeding and the
cross-device open contract.
… resets

An account change dropped background seeds but kept the remembered totals,
so another account's lists could briefly show the previous "+N".
…ut actions

The flat list hides a row's workspace label on hover so its actions can
take the space. Rows of other devices have no actions, so the label just
disappeared. Hide it only when the row has actions.
"Mark all read" cleared only the rendered device's sessions. It now also
acknowledges the loaded sessions of every other device on their hosts with
save_session_metadata limited to the unread and attention fields, clears
their rows, statuses and companion entries at once, then re-reads the
session pages. Background sessions loaded in this window pick up host read
state on reseed.
… edge

Bubbles are as wide as their column, which clips sideways, so the tinted
drop shadow and the attention pulse ring were cut off flat at the right
edge. Give the drop shadow zero horizontal reach so it fades out below the
bubble, and pulse the attention ring inside the bubble's edge.
Cast the bubble drop shadow down and to the right at 45 degrees, and give
the bubble column inline-end room for it (offset by a negative margin, so
neither the bubbles nor the pet move).
…before the pet

Widen the bubble column's inline-end room to 28px and soften the 45 degree
shadow so it falls off naturally instead of stopping near the bubble.
…card

The card showed "connect and use" with a link glyph for devices without a
background control link and "switch" for attached ones, which looked like a
difference between device kinds. Attaching is an implementation detail of
the switch; every other device now shows the same switch action.
The round glyph already says what it does; the accessible name keeps the
words. An incompatible device still explains why it is unavailable.
After a device switch the flat list re-reads its workspaces and inserted a
"loading" row under the rows it already showed, pushing "show more" down
and back. A populated list now refreshes silently; failures still show the
retry button, and an empty list still shows its loading state.
A session open on another device shared the relay link with everything the
switch woke up: git status, search index, file tree and watchers, mini
apps, and two full session-status reconciliations. While a cross-device
open runs, the peer adapter now holds low-priority requests and runs them
once it settles, and status reconciliation is deferred to one pass after
the open. set_active_workspace, which every session open needs, is high
priority.
The host accepts an acknowledgement only when it names its own record of
the unread result (unreadCompletionTarget or last turn). Clearing a session
rebuilt that receipt from loaded turns, after the unread fields had already
been cleared, so sessions whose transcript was never loaded ("mark all
read", sidebar opens of metadata-only rows) sent no or a mismatching
receipt and came back unread after a reload.

- Keep the host's receipt on each session from metadata and activity
  summaries, capture it before clearing, and send it as lastTurn and
  unreadCompletionTarget.
- Background-device acknowledgements send the same receipt.
- Dismissing a companion bubble of another device acknowledges it on that
  device's host instead of only on the rendered one.
…s without it

- The create dialog's device list labels hosts like the sidebar: Mac, Win
  or Linux for clients, CLI for command-line hosts.
- The workspace source selector offers WSL only where the host has it.
  Hosts without WSL, or whose discovery failed, show no WSL choice and no
  "could not load WSL distributions" error; a selected WSL falls back to
  the host system. Applies to local and other-device hosts alike.
- Drop sessions a background host deleted or archived from its container.
- Show a device's empty or failed first read instead of the last local list.
- Clear the session overlay and pending row action when an open fails.
- Cap how long a cross-device open holds background requests.
- Count host totals only in the unfiltered all-sessions view; read in steps.
- Adopt a host order arranged on another controller.
- Drop superseded session reads, share the capability ping, re-ask a host
  that refused a read, and poll hosts without a catalog stream.
- Register the device section and switch curtain appearance contracts.
- Activate the most recently used workspace after closing the current one.
A session tab used to stand for a workspace: opening another session of
that workspace replaced it, and a device switch dropped every session tab.

- Key session tabs by device, workspace and session, so several sessions of
  one workspace and sessions of other devices each keep their own tab; past
  12 session tabs the least recently used idle one closes.
- Keep session tabs of every device through a switch. A tab of another
  device shows its device name, renders that device when activated (the
  same open as its sidebar row), is never a pane fallback, and stays
  unselected when its open fails. Returning to a device reselects the tab
  of its active session.
- Remove the per-workspace recent-sessions menu the tabs replace.
- Subscribe the session's host stream on click, while the device switch
  runs, and hand it with its buffered events to the session's history
  owner once the device is rendered.
- Do not hold the switch on the device's config during an open; it reloads
  in the background.
- Stop polling device-control status on a host that refuses it.
…on slot

- This machine's home mark is the same glass disc other devices use for
  their action, in the same place.
- A pencil after the device name renames it in place (Enter saves, Escape
  cancels), offered only when the Relay advertises device_alias_v1.
…m the card

- Every Devices & Connections surface carries a state light: icons sit in
  tinted glass discs, connected cards glow faintly, and states are a dot
  and a word (a live link breathes) instead of filled pills.
- My devices: lit device discs, this machine's home disc in the action
  slot, quieter row actions, and renaming in place with an underline.
- Opening the dialog from the device card grows it out of the card instead
  of closing one surface and fading in another; the dialog code loads when
  the card opens.
…er-transform open

- Drop the hairline rings on the device card's arrows and discs and on the
  dialog's icon discs, step numbers, avatar, QR frames and status dots;
  surfaces are separated by tint, not outlines.
- The Devices & Connections dialog is lit like the card it grows from, with
  frosted glass cards, a quiet sidebar band without a ruled edge, a lit
  current page, inset row dividers and filled (not outlined) buttons.
- Opening it from the card is a container transform: the dialog opens as a
  window of the card's size and curve that grows to its own while a still
  copy of the card rides its corner and fades; nothing is scaled.
Colour is kept for meaning only: no tinted page light, card glows or
coloured icon discs; the current page is a neutral tint; state is a small
dot. This machine is a home glyph after its name, row actions appear on
hover, and secondary actions (sign out, disconnect, refresh) are quiet.
- A cross-device open reads a 48-event first page instead of a full
  200-event / 768 KiB page over the relay; older pages follow in the
  background. Every host honours the limit.
- The host dialog queue is polled every 2 s only while something is queued
  or running (10 s otherwise) and not while the window is hidden.
- A session open's transition ends when the session shows, not at its
  timeout, so the work it defers resumes at once.
- Activating a workspace no longer swaps the sidebar's live list for the
  background projection and back; creating a session ignores repeat clicks.
- A selected scene tab breathes while its scene is prepared; quick waits no
  longer flash a spinner (scene fallback, history paging, sidebar loading);
  the mini-app gallery shows its skeleton while its view loads.
The scene being left stays drawn, inert, above the incoming one and fades
out over it once the incoming scene is ready, instead of vanishing while
the new scene faded in from half opacity. Reduced motion switches at once.
… idle

- Switching workspace (and so opening a session in another workspace)
  shows the switch as soon as the host has switched; the recent and opened
  lists refresh afterwards instead of a second round trip first.
- Settings' default page and the common scenes load their code while the
  app is idle, so the first open renders at once.
- Switching between loaded sessions settles the transcript into place.
A persisted session whose history was still loading (and a session being
opened) was treated as new, so the composer showed its expanded new-session
layout and then collapsed when the session arrived.
One soft light from above in a single hue, the sidebar on the same lit
surface, frosted glass cards with larger radii and icon discs, and round
glass row actions that nudge forward on hover.
- A load that ends before the skeleton would show (it now fades in after
  140 ms) reveals the transcript with one 140 ms fade instead of the full
  skeleton hand-over, which held a ready transcript back for 760 ms.
- Slow loads hand over in 440 ms instead of 760 ms.
- Resting the pointer on a local session row for 120 ms starts reading its
  tail, so the history is usually in memory when the click lands.
…ed tails

- Opening a session in another workspace starts its history read before
  activating that workspace, so the two round trips overlap; the switch
  adopts the read already in flight.
- When the history budget first evicts a long session, its latest three
  turns stay in memory with their catalog range, so reopening it shows
  them at once; older turns page in as before. Only further pressure drops
  it to metadata.
The glass disc around each row's chevron rendered without its glyph, as an
empty circle.
When the top bar holds tabs of several devices, each device's tabs sit
together behind a single quiet device label instead of every tab carrying
its own device suffix. The rendered device's label reads a step brighter,
groups after the first are set off by a short hairline, and each tab is
described by its group's label for assistive tech.
…ne tab

Create dialog: drop the step subtitles, the folders-locked helper and the
'works as it does here' wording; choice cards get soft icon discs and a
hairline lift instead of an outline, devices are compact rows with an
inline host tag and chevron, back buttons are quiet, and the primary
action no longer repeats the device name from the title.

Scene tabs: the close button no longer carries a hover tooltip, and a
hovered tab that grows past the scroller's edge is scrolled fully into
view, so the last tab's close action stays reachable when the bar
overflows.
The device card's light was purple on this device and blue on a device
not yet connected. Both now share one neutral silver drawn from the
secondary content colour, so it follows light and dark themes; only a
live connection keeps its green, and an incompatible device its amber.
The Devices & Connections dialog's ambient light uses the same silver.
…ll arrow

Hovering a tab grows it by its close action. On the last tab that growth
widened the strip, so the forward scroll arrow reappeared on top of the
close button. Reserve the growth once at the strip's end and let the last
tab grow into it, so the strip's width never changes on hover; tabs that
grow past the visible edge measure against the arrow when it shows.
Opening the composer's + menu refreshes the mode catalog, which publishes
new mode objects. The target-mode tool effect keyed on that object, so it
cleared the resolved tools and refetched them, hiding 'Set session goal'
until the profile config returned. Key the effect on the mode's id,
source and default tools, refetch on explicit config events, and keep the
last known tools while the same mode re-resolves.
@bobleer
bobleer force-pushed the bob/sidebar-device-workspaces branch from 4ccf944 to 9a05508 Compare November 4, 2026 09:03
…receive ack'

The Relay always answers an acknowledged event, so a dropped ack means the
controller's own socket closed mid-call, which repeated device connect and
disconnect can do. The socket library surfaced that as 'failed to receive
ack', which reached the notification center verbatim. Map it to a stable
RELAY_REPLY_LOST message, recognise it (and the legacy text) in the Web UI,
and show a localized 'connection interrupted, try again' instead.
A peer session's first page held the newest 48 records, which could cut
the oldest turn in half. The automatic older page then rebuilt that turn
in place a few seconds after open, so the visible list jolted. The limit
is now a budget: a page grows back to its oldest turn's header within the
existing event and byte bounds, so no turn splits across pages and older
pages continue exactly before it. Older controllers accept larger pages.
…er round trips

The sidebar's new-session button swallowed repeat clicks behind a hidden
ref and gave no feedback while the session was created. It now turns
busy and disabled, and shows a spinner once the wait passes 140 ms.

Creation reads the project defaults once and shares them with the
driver, sends the mode-resolution reads together, resolves the draft's
reasoning preset and token limit together, and syncs an assistant
session's title after it is shown. On another device each of these was a
relay round trip.
Session rows listed under a device the window is not rendering had no
more-actions button. They now carry the same button and menu as live
rows (rename, copy, export, open worktree, archive; restore and delete
when archived). Each action renders the owning device first, then runs
the shared handler, now extracted to sessionRowActions; a superseded
switch runs nothing, a missing session fails loudly, and actions the
device cannot serve stay disabled with their reason. A lost relay reply
on these rows reads as a connection interruption.
A desktop controlling a CLI host could not open the composer's context
breakdown: the CLI peer host had no get_session_usage_report handler, so
the panel failed and the raw refusal reached a notification. The CLI
host now builds the report through the same runtime port the desktop
uses, and the registry marks the command handled. Against an older CLI
host the popover, preview and report panel show a calm unsupported or
update-required state instead of an error toast.
…workspaces

# Conflicts:
#	src/crates/contracts/product-domains/src/generated/remote-surface-registry.json
#	src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
…e tail

When an older page arrived while follow-output held the viewport, the
prepend compensation was refused and only the follow loop was woken. That
left one painted frame at the old offset, with the new history pushing
the reader's Turn down before the next frame snapped it back: the jolt a
few seconds after opening a session. Apply the displacement before paint
as follow-output itself, then wake the loop to keep its own target.
The add menu's additional-modes item is built from the mode's skills,
which were fetched only when the menu opened, so it arrived after the
rest of the menu and pushed it down. Warm the skills shortly after the
composer shows, or when the pointer reaches the add button, and
revalidate them in place on each opening.
…tches

Leaving a device could show its projects empty under a known session
total until the catalog re-read the host: seeding marked the rendered
device's catalog pages as projected without projecting them. The left
device is now filled at the moment of the switch from the pages the
catalog already holds (only sessions its container lacks), and the
background list shows its loading row, not 'no sessions', while a page
is unread or its listed rows are not in the container yet; 'show more'
appears only under real rows.

The live list and the background catalog also disagreed on workspace
order: the live list kept its own previous order, and several activations
on an older host between two reads looked like a deliberate rearrangement.
Both views now share one display order, judged against the shown order
and the host's last order with this window's activations applied.
…test

On macOS CI the final reserve after dropping the last lease failed once.
Other tests in the binary spawn processes, and a child forked while the
lock file is open shares its open file description until it execs, so
the flock release can trail the drop. Retry the reserve briefly; the
assertions that a held lease blocks a replacement are unchanged.
@bobleer
bobleer merged commit 7f9bae7 into GCWing:main Nov 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant