Skip to content

refactor: one client shell on one host pipe across desktop, iOS, Android and browser - #347

Open
Tryanks wants to merge 58 commits into
mainfrom
unify-client-shell
Open

refactor: one client shell on one host pipe across desktop, iOS, Android and browser#347
Tryanks wants to merge 58 commits into
mainfrom
unify-client-shell

Conversation

@Tryanks

@Tryanks Tryanks commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

The kernel and the UI already talked through one NDJSON pipe (tcode_client::HostLink), but the desktop window kept three privileged side channels around it and the phone/browser builds re-implemented a second client on top of the same pipe. This PR removes both, so every host runs the same client and the layout is decided only by viewport width.

  • Side channels removed. External import and content search are host-owned commands/queries/topics (Topic::ExternalImport, Query::SearchSessionContent). The terminal is host-authoritative: the host projects its grid as TerminalFrame/TerminalDelta, the client keeps viewport and selection locally and runs no emulator. LocalAffordances, LocalTerminalRegistry, the 256 KiB byte-replay ring and the local-host feature are gone; late attach now reproduces the host grid exactly (regression tests in crates/runtime/src/terminal_replication_tests.rs).
  • One client host seam. tcode_client::host::ClientHost (GPUI-free) with tcode_remote::NativeClientHost shared by desktop, iOS and Android, and WebHost for the browser. MobileHost and the desktop-only copies of pairing/connect/host persistence are deleted.
  • Attachment lifetime = link lifetime. crates/ui/src/attachment.rs owns a store and its views per link; switching host replaces them in place while the local kernel, mux, listener and beacon keep running. RemoteMode, the relaunch-with---connect flow and "connect after restart" are gone. Appearance, language and device name are client-owned preferences with client-over-host precedence.
  • Every product view compiles on every client. The four *_portable.rs stubs are deleted; native operations are narrow features (native-dialogs, native-preview, voice, local-permissions, pasteboard-image, remote-hosting) with runtime capability checks. Thread export is now a host query delivering bytes to the client (Save / Download / Copy), project roots are validated by the host, preview ownership is capability-aware, and tcode-services/tcode-remote leave the portable UI dependency set.
  • One responsive AppShell. Compact iff usable content width < 900 px, driven by a single observe_window_bounds subscription; compact navigation (Hosts → Threads → Thread → Panel) lives in AppShell using gpui_base::NavStack; a WindowSeam applies safe-area/IME insets once; Android Back routes through the shell. iOS, Android, web and desktop all call tcode_ui::run_shell. crates/mobile and docs/mobile-design.md are deleted; the phone preview is cargo run -p tcode-ui --example phone.

Docs updated: docs/DESIGN.md (layout rule, compact layout, capability rule, export/import contracts), docs/remote.md, README, CONTRIBUTING.

Verification

On the final commit: cargo fmt --check, cargo clippy --workspace --all-targets --locked -D warnings, cargo build --workspace, cargo test --workspace (0 failed), cargo machete, cargo check -p tcode-ui --no-default-features, iOS sim / wasm32 / Android NDK checks with -D warnings. The wasm dependency tree contains no tcode-remote, smol, tungstenite or rio-vt.

Manual: desktop default window unchanged; window dragged below 900 px enters the compact stack and returns with the same thread and draft; in-place pair + connect to a headless host, remote add-project/export/settings behave per capability; browser wide and narrow both render the shell against a headless host (tcode-headless --features web); iPhone simulator compact, iPad simulator wide split, IME insets on iOS and Android, Android Back chain through the shell.

Known gaps

  • Browser: attached wide split not driven end to end (pairing code entry through the WebGL canvas defeated the automation); the unpaired shell renders correctly at both widths and the same pairing form works on iOS/Android.
  • Android: root Back does not finish the activity on the emulator used; the shell returns "not consumed" correctly and cx.quit() is called as before, so this looks like a pre-existing gpui-android quit issue.
  • The chat command panel that renders stored ANSI output still needs rio-vt (command-ansi feature), which does not compile on Android (upstream shm_open); the portable text renderer remains for that one panel.
  • Compact uses existing owners for pairing/remove/certificate repair (the Hosts destination), settings (full route) and project choice (palette) rather than dedicated bottom sheets.

Follow-ups landed on this branch after the first description

  • Android root Back now finishes the activity: GPUI's quit callback returns false while the app is borrowed inside the Back handler, and gpui-android treated that as cancellation; it now retries the deferred quit at the end of the loop iteration.
  • Browser open-link login: tcode-headless prints https://host:port/#code=NNNNNN and /admin/pair returns browser_url; the page pairs from the fragment once and clears it. Also fixed a wasm panic in the terminal drawer (std::time::Instant).
  • Terminal scope: in-grid images (sixel/iTerm2/kitty) are no longer supported anywhere; stored command output is rendered by the host (Query::RenderStoredOutput), so no client build links rio-vt, including Android. Net −493 lines.
  • iOS rotation: all orientations allowed, iPad universal; iPad mini flips compact↔wide on rotation with thread and draft preserved.
  • Compact shell rework: one nav_bar with top-left Back on every compact screen including Settings; navigation is a shell-owned history with one Back dispatcher (toolbar and Android Back). Hosts moved out of Settings into a persistent sidebar feature area under Search (both layouts; wide renders Route::Hosts beside the sidebar). Settings → Remote keeps only hosting settings. Hosts/Pair are phone-native lists and a stacked form; navigating never detaches, only switching or an explicit Disconnect does.
  • Merged main (feat(preview): report failed navigations to the MCP tools #350 preview load errors, ci: scope checks to affected Rust packages and platforms #351 CI scoping). CI note: cargo-rail 0.25.0 rejects a --since plan whose diff contains a rename; the plan job now tolerates that and relies on the exact --from/--to plan it already computes.

…pipe

The desktop window reached around `HostLink` for two things. Import
progress rode a construction-time `ImportRoutes` bus keyed by request id,
and the command palette opened its own `SessionStore` to search session
content. Both are now ordinary protocol traffic, so a remote client and
the desktop window see the same behaviour.

Import is replicated host state, not a request-id stream. `HostMux`
rewrites message ids and drops the response route on Ack, so progress
cannot ride the originating request. `Topic::ExternalImport` plus
`ServerEvent::ExternalImportStatusReplaced` carry a host-generated
`run_id` and a Progress/Finished state; only the latest run per project
is retained, and it is cleared with the project. Clients subscribe before
sending `StartExternalImport`, so a run short enough to finish first is
still recoverable from the subscription snapshot. Completion is
runtime-owned: the importer finalizes the index in one mailbox turn and
publishes `Finished` from the next, so the index replacement always
reaches a client first, and finalization survives the initiator
disconnecting. A second live run on the same project is refused with an
`import_in_progress` error. `Command::FinishExternalImport` and its UI
intent are gone; the client no longer drives finalization.

Search moves to `Query::SearchSessionContent` /
`QueryResponse::SessionContentHits`. One host-owned `SessionSearch` is
bound to that host's store, searches host-owned session metadata in index
order, clamps the limit to 50, and does its file reads and cache locking
on the blocking executor. The palette keeps only its debounce and the
generation guard, and now offers content hits on every client build
rather than only under `feature = "desktop"`.

`SessionSearchHit` and the import progress shape move to tcode-protocol
so the service, the host and the palette share one owner instead of the
palette maintaining an imitation.

Manifest: tcode-ui declares `tcode-remote` under
`cfg(not(target_family = "wasm"))`. The `remote-client` feature reaches
smol -> rustix -> errno, which has no wasm32-unknown-unknown build;
declaring the dependency per-target keeps `cargo check -p tcode-web
--target wasm32-unknown-unknown` green even when the feature is on.
The terminal was the last privileged side channel: the desktop window read
`term::Terminal` in-process through `LocalAffordances`, while remote clients
replayed a 256 KiB byte ring into their own emulator. That replay was not
faithful — a late subscriber lost modes evicted from the window, its cursor
style, and its rows were offset after a resize — and no checkpoint serializer
can fix it from rio's public state.

The host is now authoritative for the grid. It projects `TerminalFrame` on
subscribe and coalesced `TerminalDelta`s afterwards, both built from the same
snapshot sequence, so a client attaching at any moment gets frame N then deltas
N+1…: never a duplicate row, never a gap. Cells arrive resolved (text with
combining marks, wide/spacer, interned style, OSC 8 target, per-row wrapline),
alongside cursor, raw mode bits, retained scrollback and image placements.

Clients keep only what is genuinely theirs: scroll offset, selection, link
hit-testing, and input encoding from the replicated mode bits. Desktop and
remote are identical by construction, and the drawer now works on every client
including Android and the browser, which previously had no terminal at all.

Bandwidth: `yes | head -c 2000000` (2 MB of PTY output) costs a 3.4 KB frame
plus 36 deltas totalling 36 KB — row-level diffing keeps a scrolling screen
cheap, and scrollback that outruns its budget is republished once when the
burst settles rather than streamed row by row. Row-level replace is the
deliberate simplification; cell-run deltas are the upgrade if it matters.

Placement geometry moves to the host, computed once against a viewport
anchored at the oldest retained row using the client's own cell size, so a
client only translates by its scroll offset and clips. That deletes the
client's placement layout entirely and lets `term::mappings` and the
Mode/KeyboardModes bit types move to tcode-protocol, where they belong: they
are pure functions of the wire, not of an emulator.

Deleted: `ServerEvent::TerminalOutput` and the byte ring, `LocalAffordances`,
`WorkspaceStore::attach_local`/`new_local`, the exported `LocalTerminalRegistry`
(now host-private), the `local-host` feature, and `ClientTerminal::Local`.
`term` gains a `pty` feature so clients link no PTY code, and `tcode-ui` no
longer depends on `tcode-runtime`.

`crates/ui` still depends on `term` behind a new `command-ansi` feature, off by
default for mobile and the browser: the chat command panel replays *stored*
command output through a local emulator, and rio-vt does not compile for
Android (its kitty graphics path calls `shm_open`, absent from bionic). That
is why the portable command panel survives.
Viewport and selection never cross the wire, so independence is structural.
This pins it: two models fed the same frame scroll and select apart.
The UI swapped whole modules on `feature = "desktop"` — Add Project, Settings,
thread export and Preview each had a stub twin, and the provider/ACP/orchestrate
views were compiled away entirely — even though almost all of that is replicated
store state. What is actually native is much smaller: a file dialog, an embedded
webview, macOS permission grants, dictation, the AppKit pasteboard and launching
an editor.

Each of those is now its own feature, `desktop` is their aggregate, and no view
is gated at all. Where an operation is unavailable the view says which machine
can perform it and offers what it can, and it asks two separate questions first:
does this build have the capability, and does it apply to this attachment? A
directory picker that browses this machine is withheld over a remote link even
where the build has one.

Behaviour that was wrong for a remote attachment moves to the side that owns it:

- A project root is validated by the host against its own filesystem and path
  rules, and the dialog shows the host's reason for refusing one. The client no
  longer judges a host path with `Path::is_absolute`, and `Query::IsDirectory`
  is gone with the client-side check it served.
- Thread export splits rendering from writing. `Query::RenderThreadExport`
  returns the complete artifact with a client-safe file name; delivery is the
  client's — save panel, browser download, or copy — and nothing is written on
  the host. `Command::ExportThread` and its notice/error are removed.
- Preview keeps its URL field, open-externally and copy-URL everywhere. Without
  a system webview it explains itself, answers automation with an explicit
  unsupported instead of timing out, and does not subscribe as an owner of the
  session's preview. Localhost port discovery is local-only.
- Settings shows every section remotely; only the TCC permission group is local,
  and it says to manage them on the named host otherwise. Editable fields wait
  for the host's first snapshot instead of showing local defaults, and a field
  the user has edited survives every later snapshot.
- Settings → Remote splits: connecting (saved hosts, discovery, pairing,
  certificate repair) needs only `tcode_client` and compiles everywhere;
  hosting keeps the listener and beacon behind `remote-hosting`. The pairing
  rules the phone already had — endpoint-bound pins, generation-stamped results,
  fixed origins — are now one shared form.

`tcode-services` and `tcode-remote` leave tcode-ui's normal dependencies: the
editor launcher and the WebView2 data directory are injected from the
composition root, and the workspace-relative path helper is one std::path
implementation that never touches the client filesystem for a host path.

Fixed desktop viewport heights and dialog sizes are capped against the window,
and window-drag regions sit behind a window capability rather than a page gate.
One rule replaces the platform test: compact iff the viewport minus the
system's horizontal occlusion is under 900px. Nothing else decides it —
not the operating system, not the input device, not a stored preference —
so a desktop window dragged narrow and an iPad in portrait get the same
answer, and the answer is never persisted.

`WindowSeam` is where the window's own edges live: which parts the system
covers and where the software keyboard is. That is a property of the
window, not of the host the workspace is attached to, so it is
deliberately not part of `ClientHost`. Its one content rectangle is
shared by pages, dialogs and toasts, and its bottom avoidance is
max(safe area, keyboard) — a keyboard that already covers the home
indicator does not get counted twice. Platform backends schedule a frame
when their insets move, so the seam is read per frame instead of polled.

Crossing the breakpoint must not destroy user state, so the composer now
follows the layout in place. Rebuilding it discarded the draft, its
selection and its pending attachments; the placeholder also had two
owners fighting each frame in compact, and now has one. Whether Enter
submits follows the keyboard rather than the width, because a wide tablet
still types on glass and a narrow desktop window still has a hardware
Enter key.

A compact window on the desktop is still a window: its top strips inset
past the native macOS traffic lights and host the caption cluster where
the platform expects it.
`AppShell` is now the window: it owns navigation, the back stack and the
layout rule, and it survives everything below it. Beneath it sits at most
one attachment — a link to a host and the views over that host's
workspace — so three states are possible: no attachment (the hosts list),
a local one, and a remote one. Switching hosts replaces the attachment
and nothing above it; the window's navigation root stays put, and
resizing never touches the attachment at all.

Under the breakpoint the split becomes a stack: hosts, threads, thread,
panel. Each destination is the surface the wide layout already uses —
Settings → Remote is the hosts list, the shared sidebar is the thread
list, the shared chat view is the thread, and the terminal, diff, plan
and preview are the same entities the split puts on the right. A compact
window has less room, not less product. New thread starts a draft where
the host has one project and otherwise opens the palette, which already
owns "new thread in <project>" and can search it.

Back has one order everywhere: keyboard, then the topmost dismissible
overlay, then the settings route, then the panel destination, then the
stack. It reports "not consumed" only at the root. The handle is scoped
to the window the gesture arrived at rather than to whichever window
happens to be first.

The attachment owner moves into tcode-ui and stops blocking: teardown
hands back the retiring pump's completion as a value to await instead of
joining it on the UI thread, which a browser — running the whole client
on one executor — cannot do at all. Both regression tests move with it.

Every entry point is now platform bootstrap plus `run_shell`: a
`ClientHost`, a window seam, and a window. The desktop keeps what is
genuinely its own — the local kernel, the listener, the quit prompt and
its launch flags — and supplies local attachment as one closure. That
leaves nothing in `crates/mobile`, so it is gone, along with the locale
keys only it used; its phone preview is now a tcode-ui example that opens
the same shell at phone geometry.
The layout rule, the compact destinations, the back order, the one insets
rule and what a breakpoint crossing must preserve now live in the design
spec, where the rest of the visual contract is. `docs/mobile-design.md`
described a shell that no longer exists; its still-valid touch,
navigation, connection and input contracts are folded into "Compact
layout" and the file is gone.

The remote guide stops calling the browser and phone a separate compact
client: they run the same app, and what actually differs between clients
is which operations they can perform locally, which is the capability
rule the design spec already states.
The built-in terminal renders text. Sixel, iTerm2 and kitty graphics are
gone: rio-graphics, rio-vt's `graphics` feature, the image and overlay wire
types, their host projection and the client's image registry and overlay
painting all go with them.

rio-vt now lives only on the host. Stored command output in the chat timeline
is re-rendered there through the same `GridEmulator`: a client measures the
width it can show and asks `Query::RenderStoredOutput` for that item at that
many columns, and gets back one `TerminalFrame`. `term` leaves tcode-ui's
dependencies entirely, and with it the `command-ansi` feature and the
plain-text fallback panel that existed because Android could not link rio.

The panel shows the raw text until the host answers, caches the answer per
width, debounces a width drag by 120ms and holds at most four renders in
flight so scrolling a long thread cannot flood the host. An answer for a
width the panel has left is dropped by generation.
The host was both phone-only and portrait-only, so UIKit could not deliver rotation geometry to GPUI. Make the app universal, declare the device-specific orientation masks, and refresh keyboard/safe-area geometry before forwarding resized bounds.
…7a0-8238-2a5599547a95

# Conflicts:
#	crates/app/src/preview_smoke.rs
#	crates/ui/Cargo.toml
#	crates/ui/src/preview_panel.rs
The window kept three competing navigation authorities: a compact
`Destination` addressed by depth, a `route` field, and the settings
page's own `compact_detail`. Back could only unwind the first, Settings
closed outright, and walking back to the hosts list detached the window
from its host. Compact wore three header styles and two Back positions.

`WindowState` now owns one history. Every destination — Hosts, Pair,
Threads, Thread, Panel, Settings and one settings section — is an entry
in it; the compact stack mirrors it entry by entry, and the wide route
is derived from whichever entry is on top. One `back` dispatcher pops
that history for the toolbar control and the Android gesture alike, so
Settings section → Settings → the page it was opened from, and Pair →
whatever pushed it, need no special cases. Navigating never detaches:
only connecting to a different host (which restarts the history at that
host's threads) or an explicit Disconnect does.

Hosts leaves Settings. It is a product surface answering one question —
which host am I talking to — reached from a new persistent feature area
in the sidebar at both widths, wide as a route beside the sidebar and
compact as the navigation root. Its rows are touch-sized: name,
endpoint, last-connected time, a status glyph for the attached host, and
a menu carrying Disconnect and Forget. Pairing is its own page with
labels above full-width fields and the primary action pinned above the
keyboard. Settings → Remote keeps hosting this computer, and only where
the client can host at all.

Switching hosts from the UI never worked: `run_shell` took the shell out
of the cell the switcher closure held, so every switch was a silent
no-op. Both switching and detaching now resolve the window's shell
through the same window-scoped handle Back uses.

Compact settings rows stack their label and description above a
full-width control, so prose wraps instead of overflowing.
Record the contract the code now keeps: one nav bar on every compact
page including Settings, one history that Back unwinds a step at a time,
and navigation that never detaches. Describe Hosts and Pair, the
sidebar's feature area and its row anatomy, and the compact rule that
puts a row's label above its control. Settings → Remote is hosting this
computer; connecting is the Hosts surface, so README and the remote
guide point there instead.
The compact Panel destination mounted the desktop right panel verbatim: a
second tab row under the segmented control, the column's expand / split /
close cluster, and bodies flush to the window edges with git errors cut off
mid-word.

Compact now owns the page. The segmented control is the only selector, so the
diff panel's tab row and window controls are not built at all, and Preview
drops its close button — Back leaves the page. What is left is one toolbar row
on the page inset with 44pt touch targets; the diff's view toggles, the
terminal's split/new/restart/close and the preview's URL actions move into an
overflow DropdownMenu.

Diff, Plan and Preview bodies take the 16pt page inset; notices wrap inside it
and code scrolls sideways within the body instead of off the page. The
terminal grid stays edge to edge, as it is measured in columns.

Two overflows the audit turned up on the way, both at both widths: a work-log
detail (`w_full` + `ml_2`) is 8pt wider than the column it sits in, so the
inline diff and command output ran past the page — that indent is now padding
on a full-width wrapper (`material::rail_detail`); and Settings → Other
devices kept desktop two-column rows, squeezing its descriptions to a word per
line, so it now follows the same rule `SettingsPage::row_frame` applies.

docs/DESIGN.md records the rule: page inset 16pt, card inset 12pt, terminal
exception.
…tings

Back names a kind of place, not the page under it: every destination owns
one short fixed label, so a long thread title can no longer truncate a
Back control into "‹ Build M…" and the nav bar reserves the same room on
every page. The compact Threads page is titled Threads with the attached
machine as its subtitle.

One list style across the product surfaces: threads, machines and nearby
machines are plain rows at the page inset with hairline separators and one
caption style, while settings-like forms keep the grouped card. A machine
row's overflow trigger now sits inside the row, so the hover fill covers
the whole row instead of stopping short of a seam beside it.

Wide Settings keeps the workspace sidebar, as the Machines route already
does: it replaces the content column, its header carries the same Back
control, and the sidebar's Settings entry shows the selected surface while
the route is showing. The rail's duplicate wordmark and its back row are
gone with it.
…ttings

Opening Settings switches the whole window again, as it did before the
sidebar was put beside it: the left column is the settings rail, which
carries the wordmark, clears the platform's window controls, groups its
sections under the two captions and ends in the row that leaves the
route. Machines keeps the sidebar; that one is meant to be persistent.

Computer Use and Browser configure a screen the agent drives, so they
move to the end of the machine group under a collapsible Advanced
disclosure — collapsed on a client attached elsewhere that drives no
screen of its own, expanded wherever it does. A deep link to one of them
opens the disclosure rather than selecting a hidden section.

A project header separates one project from the next, so the compact
thread list draws it only from the second project onwards. "This
machine" now reads as the row it sits among: title over one muted
subtitle, no leading icon, the connection dot in the same slot.

The terminal key bar orders its symbol tail by how often a shell line
needs the character and adds one-tap ^C and ^D after the arrows. A combo
carries its own Control through the same key_bytes path a hardware
Ctrl+C takes, and consumes any sticky modifier rather than doubling it.
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