Rebasing #211 onto #240 moved the session-id token gate out of
resume_command and into the shared session_command_flags helper, so
Pi's comment pointing at "the token gate above" no longer names
anything. Comment only; the gate itself is unchanged.
The fork row said "Branch Session" on Claude Code and "Fork Session"
everywhere else, on the strength of a source comment claiming "Claude Code
calls it branching". It does not. `claude --help` documents the flag as
`--fork-session`, described as "When resuming, create a new session ID instead
of reusing the original"; the only occurrences of "branch" in its help are an
unrelated git-branch review option. The claim came from otty's own UI wording,
which I mistook for Claude's vocabulary and then wrote into the source as
fact — so the comment goes with the special case rather than being left behind
as a false statement about someone else's tool.
The split was also inconsistent with itself: Grok takes the identical
`--fork-session` flag and was already labelled "Fork Session". Every agent that
has the capability calls it forking — `codex fork`, `--fork-session` on Claude
Code and Grok, `--fork` on OpenCode — so one wording covers all four.
`fork_label` keeps returning `Option<&'static str>`: it is still the UI's single
capability gate (`None` = no verified fork command, no row offered), and
per-agent wording stays expressible should one ever genuinely diverge.
Generated commands are untouched — the existing table test still pins
`claude --resume <id> --fork-session` and the other three verbatim.
Also drops the two doc sentences that promised the per-agent label, and the
stale "Branch Session" mentions left in comments; no occurrence survives
anywhere in the tree.
A coding-agent conversation is a single thread: to try a risky direction you
either lose the one that got you there, or you don't try it. Every agent tty7
resumes already knows how to branch — `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — but nothing in tty7 reached them, so the capability was
invisible from the terminal that already knows every pane's session id.
Fork is a per-agent capability beside the existing resume table
(`CLIAgent::fork_command`), not a Codex special case: it is the same `match
self` shape, it reuses the same id validation and the same launch-flag replay,
and four installed agents qualify today. Every command was checked against that
CLI's own `--help`; agents with no fork tty7 could verify return `None` and are
never offered the action, since a guessed flag shape would only ever produce a
usage error in the pane.
Flag replay needed one correctness fix to survive this. A forked pane's own
argv *is* a fork command, so relaunching it would replay the stale subcommand
and id (`codex fork <old>` → an old id as a positional prompt) or double the
modifier (`--fork-session --fork-session`). `codex fork` now sheds its
subcommand exactly as `codex resume` did, and `--fork-session` / `--fork` join
their agents' stale session-targeting lists. That also settles restore: a forked
pane restores through `resume_command`, which now drops the fork flag — a
restart continues the fork rather than branching it again.
Placement follows where the user asked from. A pane-level ask is spatial, so
the pane right-click menu offers Split Right / Left / Down / Up (pane splits
gained a `before` slot for the Left/Up half, which the tree had no way to
express). A tab-level ask isn't, so the tab context menu — inherited verbatim by
the sidebar rows, which is where the request came from — opens the fork in a new
tab with no placement question. The bare action behind the palette, the File
menu and Settings → Keybindings takes the tab-level meaning.
The three ways a fork can't run all surface rather than no-op: no session id
yet (hooks not installed) and a remote pane (the command would shell the *local*
agent) render the row disabled instead of hiding it, so the capability stays
discoverable, and the action paths that have no row to grey out say so in a
notification. Forking mid-turn is allowed but announced — agents fork from the
persisted transcript, so the turn in flight is absent from the copy — and the
parent is untouched either way.
Copy Session ID sits beside Copy Working Directory. Codex has no
copy-or-duplicate subcommand, so "copy the session" is the id: paste it into
`codex resume`, a bug report, or another tool.
Deliberately not built: any reading or writing of an agent's own session files.
tty7's exposure stays the public CLI contract plus the hook payload's session
id, so a change to Codex's rollout format or its version-numbered SQLite index
costs at most a visible shell error. Forked tabs also look exactly like their
parent, by decision — "Rename Tab" is the answer.
Closes#211
The burst case asserted at most 4 batches for 50 writes, which really
asserts the machine finishes 50 writes inside 400ms. A loaded Linux
runner spread the burst over ~1.4s, so the coalescer correctly emitted
one batch per 100ms window and the test went red on main.
Measure how long the burst actually took and allow ceil(burst /
WATCH_COALESCE_WINDOW) + 2 batches. A host that stops coalescing still
fails: dropping the window to 0ms turns the case red.
The `to_frame` guard reached for a Latin-1 filename through
`os::unix::ffi`, in a test module that is not Unix-only. It now asks
each platform for a path it accepts and `str` cannot hold — Latin-1
bytes on Unix, an unpaired surrogate on Windows — which compiles there
and, more to the point, actually asserts the refusal on both.
The Pi extension conflicted: main gave it the session-id forwarding
doc, this branch had already moved it from `current_exe` to the target
machine's binary. Both kept.
`a_detached_result_lands_after_its_view_is_dropped` now pumps to a
deadline instead of parking once — a `Host` call runs on `HostOps`' own
pool rather than gpui's executor, so there is nothing for
`run_until_parked` to wait on until the answer has already crossed
back.
Five findings from review, all about where blocking work runs and what
a stale handle is still pointing at.
- `live_pane_count` ran a routed `List` — an SSH handshake, and on a WSL
route as far as installing the server — straight from the Stop/Delete
action handler. That is `guard_off_ui`'s debug abort in a dev build
and a frozen window in a release one. It is now split into a UI-thread
read and a background count, with the prompt raised through the window
handle afterwards.
- `teardown_workspace_forwards` blocked the UI thread on a daemon reply
that waits for the SSH server to acknowledge `cancel_tcpip_forward`.
On a machine that has gone unreachable — exactly when someone reaches
for Stop Workspace — it never came. Backgrounded, and `on_workspace`
now sets a read timeout so the thread is not parked forever either.
- The file tree's and editor's watch subscriptions had no record of
which host opened them. A reconnect inserts a fresh `RemoteHost` under
the same `HostId`, so `set_dirs` failed on a dead `ControlClient`,
was warned and dropped, and nothing opened a new one: after the first
reconnect the tree stopped seeing remote changes for the life of the
window, and the editor's external-change detection — what stops a save
clobbering someone else's edit — was silently off. Both now compare
the host by pointer and reopen when it differs.
- Closing a remote window that was empty *because its machine could not
be reached* deleted the workspace: its `RemoteRef`, cached layout and
geometry, while its panes were still running over there. Only a
machine that answered licenses dropping the entry.
- `HostOps` ran blocking calls on gpui's background executor, which on
Linux is a fixed pool with no blocking tier. Four stalled host calls
on a four-core client took every worker, including the one the
reconnect needed to clear the stall. They now run on their own elastic
pool.
Two scheduling bugs on the server side of a connection.
The pool asked `idle == 0` before spawning, but `idle` counts a worker
from before it parks until after it has re-acquired the lock on its way
out — so through the whole wake-up window a worker already handed a job
still looked free, and the second `notify_one` in that window went to a
thread that had left the wait set. A client pipelining k+1 frames onto k
parked workers left the last one queued behind a `git status`. The rule
is now "more queued than parked", which counts both sides of the window
and cancels it out.
`WatchOpen` started its forwarder before the reply carrying the watch id
was written, and both go through the same sink. A directory that changed
in that instant could push a batch the client dropped — it files the id
only once `call` returns — and since the tree relists only on a watch
event, that change stayed invisible. The forwarder is now parked and
started by `finish`, and dropped outright when no reply went out, so a
cancelled `WatchOpen` no longer leaves an OS watch behind either.
The takeover moves two things — the `WorkspaceStore`'s record and the
server's `AttachRegistry` handle — and each was internally locked, which
is not the same as the pair moving together. Two clients attaching one
workspace at the same instant could each win a different table, after
which the store named a session the registry had already evicted and no
`detach` could clear it: the workspace reported a takeover against a
client that had disconnected hours ago. Both moves now happen under one
handover lock, dropped before the displaced client is written to so a
peer that has stopped reading still cannot hold up the next attach.
`WorkspaceDelete` had the same split with no race needed at all: the
store dropped its attachment and the registry kept its handle, so the
next client to attach that id evicted a session nobody displaced — and,
that entry being dedicated, closed its whole link.
Two `tty7-server --stdio` sessions arriving while no daemon was up each
served in-process, each with its own store over the one file. `persist`
writes the whole document, so the second to save silently dropped the
first's changes, and their separate registries made takeover a no-op
between them. The probe path now starts the daemon and bridges to it —
the rule `bridge_panes` already follows one dialect over — and the store
re-reads when the file has moved underneath it, which covers the cases
where two writers are deliberate.
`MAX_RECORD_BYTES` and `MAX_WORKSPACES` did not bound their product:
seventeen maximal records put the array past `MAX_FRAME`, after which
every `WorkspaceList` was unencodable and every client showed an empty
list. The document is now bounded at the save, and only when growing, so
an over-large file can still be deleted back under the limit.
Six findings from review, all in the same seam — what happens when a
message cannot go on the wire, and who is allowed to conclude the link
is dead from that.
- `Sink`/`ControlClient::send` encode before writing, so 'this message
cannot be serialized' and 'this link failed' are distinguishable. Only
the second can have put bytes out, and only the second is grounds for
marking the connection dead — the client used to go `Reconnecting`
over an oversize `WriteFile` the server never saw.
- `Conn::finish` answers an unencodable reply with the error instead of
dropping it. Dropping left the client waiting out the request's whole
deadline for a reply that was never coming.
- `Search` drops the hits whose paths are not UTF-8 rather than letting
one Latin-1 filename make the whole reply unencodable. `SearchHit` is
the only `PathBuf` on this wire; serde refuses such a path outright.
- The control socket is bound under a tightened umask. `bind` creates
the node at `0777 & ~umask` and the `chmod` was a window — under
`umask 002` a group-connectable one, onto unauthenticated `ReadFile`.
- Binding no longer re-permissions a directory it did not create. With
`$TTY7_CONTROL_SOCK` or the hashed fallback the parent can be `/tmp`,
and 0700 there locks every other user out of it.
- A blob is filed under the `pending` lock, so a caller timing out in
the gap cannot leave a whole file's contents in the side table for the
life of the connection.
* feat(agents): make Pi a first-class agent, not a fallback one
Pi panes drew the generic robot glyph every unbranded agent shares, so a
Pi tab was indistinguishable from an Aider or Qwen one in the sidebar,
the tab chip and the tray menu (#225). Auditing the rest of the registry
turned up two more places Pi was on a default rather than handled.
The avatar. Repo practice, from the most recent addition (eced0af,
Grok): take the vendor's mark where it is usable as a 16px silhouette,
otherwise lobehub/lobe-icons' transcription (MIT) with the notice inside
the SVG. Neither applies here — lobe-icons' "Pi" is Inflection AI's
chatbot, a different product whose trademark has no business on this
agent, and Pi itself (earendil-works/pi, MIT) ships no symbol to
transcribe. So this is tty7's own geometric Greek pi on the same 24x24
grid, ~3.3 stroke weight and rounded terminals as the bundled marks,
drawn as a filled silhouette because gpui rasterizes these to a tinted
alpha mask. The letter is not a trademark; nothing is vendored in. The
sky accent and the status dot are unchanged.
Resume. Pi's `--resume`/`-r` is a *boolean* that opens the interactive
picker and `--continue`/`-c` just takes the newest session; the flag
that targets one by id is `--session <path|id>` (its own flag table,
packages/coding-agent/src/cli/args.ts). So the resume command is
`pi --session <id>`, and the stale-flag list gains an arm for the five
ways to name a different session — `--session`, `--session-id`,
`--fork`, `-r`/`-c`, and `--no-session`, which would turn saving off
entirely. `--session-dir` is deliberately not stripped: it says where
sessions live, so the injected id needs it to survive.
None of which pays off unless tty7 knows an id, and it did not — the
generated Pi extension spawned the emitter with stdin ignored, so every
event arrived with session_id: None and resume_command was never
reached. The bridge now reads Pi's id off ctx.sessionManager
.getSessionId() (exposed on the read-only session manager Pi hands each
handler) and pipes it in as the emitter's JSON payload, on session_start
— which also fires for /resume, --fork and new sessions, so a mid-pane
switch re-reports instead of going stale. The load-time presence ping
stays bare; no context exists yet.
Left alone deliberately: aliases, slug, display name, accent and the
hook install/uninstall integration were already correct, and every
render site (tab chip, sidebar row, tray menu, notifications) is generic
over icon_path/accent_rgb/display_name — no other agent changes.
Guard tests, following what the module already does: the fallback set is
pinned by slug so neither adding a mark nor regressing to bot.svg can
pass unnoticed, the Pi resume form and its flag stripping are asserted,
and the Pi bridge is checked for the stdin plumbing whose absence was
the silent half of this bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* no-mistakes(review): skip resume for Pi panes launched with --no-session
* no-mistakes(document): align Pi changelog flag list with stripped flags
* fix(agents): use Pi's own mark for the avatar, not tty7 artwork
The Pi avatar shipped as original artwork on the claim that Pi publishes
no symbol. That claim was wrong: Pi's mark is at pi.dev/logo-auto.svg.
Swap the drawing for the published one and correct the provenance notes
that repeated the claim (the SVG header, the asset-source arm and the
changelog entry).
The published file is the one mark in this set that arrives with a
stylesheet — an 800x800 box whose `prefers-color-scheme` block swaps
black for white. usvg renders it anyway (it applies the base rule and
ignores the media query), so this is normalisation rather than a fix:
geometry rescaled to the 24x24 grid the rest of the set uses, class and
media query dropped for the flat sentinel fill, since gpui and the tray
both tint these as alpha masks. At this size the mark lands on an exact
4x4 grid of 6-unit cells, so the rescale is lossless.
The tray's avatar test now walks the whole roster instead of one branded
and one fallback agent, and asserts the disc came back with more than one
opaque colour. It is the only test that runs the bundled SVGs through
resvg — the asset-source test proves the bytes resolve, not that they
parse into visible geometry — and a mark that parses to nothing renders
as a bare accent disc that nothing else would catch.
Refs #225
* no-mistakes(document): correct Pi changelog icon-grid claim
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A Windows `SystemTime` is a FILETIME, whose tick is 100ns, so
`UNIX_EPOCH + Duration::new(_, 123_456_789)` came back as
`123_456_700` and the assertion failed on a rounding this conversion
never saw. Every nanosecond figure in the case is now a multiple of 100,
which still exercises the full nanos field.
`--stdio` is refused on Windows by design, and the control socket it
probes for is Unix-domain, so `stdio_conformance` and `workspace_store`
join `remote_router`/`routed_pane` in carrying a file-level `cfg(unix)`.
`cli.rs` keeps its argument-handling cases everywhere — `--version`,
`--help`, `agent-hook` and the usage error say nothing about transports
— and gates only the bridge and probe cases, which spawn a `--stdio`
child or stand up a listener.
Every test in `host::server`'s test module talks over a `UnixStream`
pair, so the module needs the same `unix` gate the `tty7-server`
integration tests already carry — without it the Windows leg of
`build & test` fails to compile `tty7-core`'s lib test with 19 errors.
The three pool tests are plain threads and channels, so they move to
their own module rather than being gated away from a platform they
work on.
The gpui keybinding tests in `ui::app` never pinned a config dir, so every
`update_config` they run ends in `Config::save()` — a *full* overwrite — against
the developer's real `~/.config/tty7/config.json`. A plain `cargo test` reset the
whole live config to `Config::default()` plus the shortcut the test recorded;
the user-visible symptom was a rebind reappearing days after it was reset.
Guard it at the type level rather than trusting each test to remember: a
`#[cfg(test)]` `Config::save`/`load` on the GUI newtype panics when the resolved
config dir is the platform's real one. An inherent method wins over the `Deref`
to `CoreConfig`, so under `cargo test` every call site routes through the guard
and otherwise straight to core — no call site opts in. `default_config_dir` is
pub now so the guard compares against it instead of re-deriving the layout.
`set_config_dir` is first-call-wins and process-wide, which means every test has
to pin the *same* path: a test pinning a name of its own silently redirects
whichever test loses the race. Nine hand-copied `tty7-covtest-<pid>` literals now
go through one `pin_test_config_dir()`. f0cfb47 carried an earlier cut of this
fix that pinned `tty7-kbtest`, which is exactly that failure — it broke
`submitted_command_backfills_its_exit_code`, green again here.
The section was a field editor wearing a list's clothes: the widest
column on the page said "Select a profile to edit" and nothing else, the
global security toggles hung underneath whichever host happened to be
open, and port forwards had to be typed as `L bind:port target:port` into
a text area that taught the syntax nowhere and silently dropped any line
it couldn't parse.
The master column now leads with a title and a filter — past a dozen
hosts, finding one is the job — with Add and the `~/.ssh/config` import
demoted to icon affordances beside it. Hosts bucket by group into
collapsible sections; a collapsed header keeps showing how many of its
hosts are connected, and collapsing the group holding the selection hands
the detail pane back to Defaults rather than stranding a selected row
nobody can see. A live query force-expands every group.
`Defaults` is now a pinned row with its own page, so "every host starts
from these and can override one" is something the list's shape says. Each
per-host override names the value it currently inherits instead of
claiming a mechanism exists.
A selected host leads with its own name, address, jump chain and live
status, then Save (disabled with nothing to write) and Connect. Save
leaves the form open — with the list permanently beside it, closing back
to an empty pane read as the selection being thrown away.
Port forwards are one row of inputs per rule, and a rule that can't
connect is flagged in place rather than dropped on save.
With nothing selected the pane offers quick connect and, when
`~/.ssh/config` holds aliases tty7 hasn't linked, an offer to link them.
Hook installation was written for one machine — this one — and a remote
workspace runs its agents on the far side of the connection, where none of
that is true: a different `$HOME`, a different filesystem separator, and a
`tty7` binary at a path this client published rather than the one it is
running from.
`HookTarget` is that machine, borrowed for the length of one background
task: `local` resolves our own home and binary, `remote` takes the home a
handshake reported and the `tty7-server-<version>` this client installed
there. Every path the installer builds now goes through it, via `Host::join`
rather than `PathBuf::join` — a Windows client installing onto a Linux box
was writing `/home/me\.claude`. The three things that are only true locally
(our own environment variables, atomic writes, running the `codex` CLI) are
gated on `is_local` instead of assumed. Settings grows a machine picker so
the page states which one it is acting on.
Also in this commit, three unrelated UI fixes:
- The settings sidebar's search placeholder sat 6px right of every nav
label under it — a `small` (14px) magnifier where the rows use 16, a 4px
gap where they use 8, and an `Input` that adds `input_px` (12px at the
default size) whether or not it draws a box. All three corrected, so the
placeholder starts on the rows' 32px text column.
- The "'X' is still running — reopen it from the workspace menu" toast is
gone, along with the `workspace_detach_hint_seen` flag that existed only
to show it once. Detaching a workspace is what ⌘W has always done here
and the Window menu already lists what came off screen; a one-time
lecture on top of that is noise. Old configs carrying the key still load
— `Config` doesn't deny unknown fields.
540255e dropped them to TILE_SIZE_SM to buy width back after the panel's
top row overflowed a 200px column, and used the size step to separate the
panel's own tabs from the window chrome sharing that row. Both arguments
hold. Neither survives what it looks like: a 24px tile with an 11px glyph
next to a 32px one with a 13px glyph doesn't read as a layer below, it
reads as shrunk — the panel's primary navigation drawn smaller than the
two buttons in the corner.
The width it was buying comes from MIN_WIDTH instead, which is where that
constraint honestly lives. Six 32px boxes, five 2px gaps and two
glyph-aligned insets need 214px, so the floor moves 200 → 216. The panel
was never usable at 200 anyway; what a tighter floor bought was an
overflowing row.
Reverts the tile size, the `rounded_md` that tracked it, and the two
insets that were switched to their `_sm` variants to match.
Folding three signed-off values into one `SELECTED` target is what made
the chrome shout. The old ladder had `hover` 0.09, `sidebar_sel` 0.12 and
`list_active` 0.17; the ratio rewrite kept only the loudest of them and
pointed every selected fill in the app at it. On the Light theme the
rail's selected row went from #E2E2E2 to #C0C0C0 — a silver slab at twice
the perceived step it had ever had — and it took the switch tracks, the
input grounds, the plain buttons' active state and every chrome tile's
*hover* (which paints the sidebar's selected grey by design) with it.
The two jobs were never the same:
- A resting selection — a rail row, a lit toggle, a switch track — sits
there all session next to unselected siblings. It stays quiet and leans
on the text channel, which is what `Surface` carries both for.
- A cursor — the one menu row under the pointer, the palette's keyboard
row — is transient and alone on its surface, and the eye is already
tracking it. It gets the loud rung.
So `SELECTED` drops to 1.30:1 and a new `CURSOR` takes 1.70:1, each
anchored to the Dracula value it was signed off at (`mix(bg, fg, 0.12)`
and `0.17` respectively). Dracula lands within 1.02:1 of both old greys,
and every other theme is pulled onto the same two perceived steps instead
of the same one. `PRESSED` follows `SELECTED` down to 1.55:1 — it is
"one step past selected", not a fixed distance from the surface.
Only `accent` (gpui-component's name for a menu row highlight) and
`list_active` move to the new rung; everything else was already asking
for a resting selection and now gets one.
The per-surface ladder test drops its floors to match and gains a
cursor-vs-selected assertion, so the two rungs can't quietly re-merge.
Opening, restoring, closing or listing a remote pane each ran a routed,
blocking connection straight from a gpui input callback, freezing the whole
window for the length of an SSH setup. Pane tree leaves become a PaneSlot that
can be Ready or Connecting, so the slot lands in the layout immediately and the
wait happens in it. connect_routed now carries the same debug UI-thread guard
the Host trait uses, so the class of bug fails loudly instead of shipping.
The workspace switcher is redrawn on one alignment axis with one icon size, a
guide rail tying a remote machine's rows to it, and the command palette's card
geometry. Launch restores only the workspace the user was last in.
The panel's title-bar-height top zone carried seven controls belonging to
three different layers: four panel tabs, the panel toggle, the "..." menu,
and the workspace chip. All seven at chrome scale, in a column the user can
drag down to MIN_WIDTH. The row wants 268px there — it overflows by 68px
before anything else goes wrong.
The layers are the real defect; the crowding is a symptom. Switching to
Files is "what does this panel show", the workspace chip is "which machine
am I on", and they sat side by side, same size, same weight.
Three changes, none of which touch the window layout — both columns stay
full-height and the corner controls stay where they are:
- The workspace chip moves to the head of the rail, as a full row with the
workspace's name rather than a monogram in a corner. The rail below it
enumerates that workspace's tabs, so this is where the name belongs; it
also stops a window-scoped control from competing for the panel's width.
Note it is *not* folded into the repo group headers under it — those are
repositories, and one workspace holds several.
- The panel's tab tiles drop to TILE_SIZE_SM. That constant exists for
tiles inside a panel, which is exactly what these are, and the one size
step separates them from the window chrome sharing their row without
spending a divider on it.
- On macOS panel_title draws nothing. The tile row above it already says
which tab you are on, and so does the content — a file tree is Files, a
diff is Changes. It cost a whole row: tiles, then a title, then a search
box, before one line of content. Changes' file count moves into that
tab's tooltip, where it stays readable without switching tabs. Outline's
count does not survive: it needs the active leaf, which needs a &Window
right_panel_tabs has no reason to take, and its list is one click away.
A tab passing `trailing` still gets the row; none currently do.
Off macOS nothing changes: the panel hangs below a spanning title bar
there, and panel_title is still that panel's tab switcher.
The top zone now needs ~164px against a 200px minimum.
resvg 0.47 landed in tty7 (#227) and the gpui fork (#237), but
gpui-component still declared its own resvg = 0.45.1, keeping a
legacy resvg/usvg/tiny-skia 0.45/0.11 stack in the tree. The fork now
pins 0.47 (l0ng-ai/gpui-component@2264ff99 — no source changes needed;
its only resvg user, the Windows native-menu rasterizer, uses APIs
unchanged across the bump), so this moves the pin and drops the last
duplicate: the lockfile now carries a single resvg/usvg/tiny-skia
stack at 0.47/0.12, and `cargo tree -i resvg@0.45.1` matches nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #227 bumped tty7's direct resvg to 0.47 while the gpui fork still
pinned 0.45, so the tree compiled two resvg/usvg/tiny-skia stacks. The
fork's tty7 branch now carries resvg 0.47 (l0ng-ai/zed@3aac3ef); move
the gpui pin there so gpui's SVG renderer and tty7's tray-icon
rasterizer share one 0.47 stack again.
gpui-component still declares its own resvg 0.45.1 (semver-incompatible
with 0.47), so one legacy 0.45 stack remains until that fork catches up
- noted in the manifest comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A remote workspace came back empty every time. The layout was thrown away
on both sides of design §10's storage split: `record_session` blanked a
remote entry's `session` before anything could push it, and
`claimable_session` blanked it again on the way out, so the record pulled
from the machine on connect was discarded the moment the window opened
from it. Nothing ever called `push_remote_layout` outside workspace
creation either, which left the machine's own `workspaces.json` holding a
record with no tabs in it.
The entry's `session` is now what it was always meant to be — this
client's cache of a record the machine owns — and the split is enforced
by reachability instead of by erasure:
- `claim` / `record` ask `machine_is_connected` first. A connected remote
window stores and reopens its layout like any other; an unreachable one
opens empty and, the half that matters, leaves the cached layout alone
so there is still something to rebuild from.
- `save_session` pushes to the machine that owns the record, so the
remote's copy tracks every structural change rather than only the
workspace's creation.
- `finish_attempt` hydrates a window that came up before its machine did.
Only an empty one: a window with tabs is one the user is working in.
The invariant the erasure was protecting still holds, and holds where it
belongs — every leaf routes through `pane_workspace_for`, so a remote
workspace's panes attach or spawn over there, and an unreachable machine
fails the spawn rather than falling back to a local shell.
Also stop matching a native-SSH leaf's `pane_id` against a remote
workspace's alive set. That pane lives in this client's daemon however
the window is bound, and pane ids are unique only within one daemon, so a
collision would have swapped the user's SSH tab for whatever the machine
happened to be running under the same number.
A watcher event dropped the cached listing for the directory it touched, so
on a remote host every row under that directory left the screen for a whole
round trip and came back — a file rewritten a few times a second made the
tree strobe. Mark the listing stale instead: it keeps painting while the
replacement flies, and is swapped out when that lands.
Also stop relisting a directory for its own watcher event. Whatever changed
inside it arrives as an event on that child, while the directory's own event
fires for everything inside it including the dot-files the row filter skips
on purpose — so relisting for it handed back exactly the round trip that
filter exists to save. A remote $HOME with an agent rewriting
`~/.claude.json` went from two relists a second to none.