diff --git a/.gitignore b/.gitignore
index 209b0ce..28d187c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,5 @@ flued
/site/src/routeTree.gen.ts
# Wrangler's local cache from site deploys.
/site/.wrangler/
+# tsc --noEmit with incremental drops this beside web/tsconfig.json.
+web/tsconfig.tsbuildinfo
diff --git a/internal/daemon/conn.go b/internal/daemon/conn.go
index 7b28219..c0c0536 100644
--- a/internal/daemon/conn.go
+++ b/internal/daemon/conn.go
@@ -64,6 +64,13 @@ const (
peekMaxBytes = 128 << 10
)
+// capMultiplex is the welcome capability that says this daemon understands
+// session groups and ephemeral sessions — the optional spawn and update
+// fields added for the in-session multiplexer. Feature detection, not
+// negotiation: the fields are additive either way, the cap only tells a
+// client whether the affordances are worth drawing.
+const capMultiplex = "multiplex"
+
var (
errConnClosed = errors.New("daemon: connection closed")
errConnBacklogged = errors.New("daemon: client is not draining its socket")
@@ -445,6 +452,12 @@ func (c *conn) serve() {
DaemonID: "local",
Host: c.srv.hostname,
Ver: c.srv.version,
+ // What this daemon can do beyond the base protocol, for a client to
+ // feature-detect on. "multiplex" says spawn accepts group and
+ // ephemeral, and update accepts ephemeral: a client talking to a
+ // daemon that does not say it hides the split and scratch affordances
+ // rather than spawning sessions whose extra fields silently dropped.
+ Caps: []string{capMultiplex},
// Read here, once, at the moment this connection opens. The status is
// not a stream — nothing pushes an update when the relay reconnects —
// so what a client holds is what was true when it arrived, which is
@@ -591,7 +604,7 @@ func (c *conn) handleControl(msg any) {
// spend that distinction on the way past. wire.Update and
// session.MetaPatch have the same shape for exactly this reason.
if _, err := c.srv.reg.UpdateMeta(m.ID, session.MetaPatch{
- Name: m.Name, Tags: m.Tags, Pinned: m.Pinned,
+ Name: m.Name, Tags: m.Tags, Pinned: m.Pinned, Ephemeral: m.Ephemeral,
}); err != nil {
// The only thing UpdateMeta refuses is an id it does not hold, and a
// client editing a session that has just exited and been reaped is
@@ -682,6 +695,7 @@ func (c *conn) handleControl(msg any) {
case wire.Spawn:
s, err := c.srv.reg.Spawn(session.SpawnOpts{
Cwd: m.Cwd, Cmd: m.Cmd, Cols: m.Cols, Rows: m.Rows,
+ Group: m.Group, Ephemeral: m.Ephemeral,
})
if err != nil {
c.sendErrorFor(m.ReqID, "spawn_failed", err.Error())
diff --git a/internal/daemon/multiplex_test.go b/internal/daemon/multiplex_test.go
new file mode 100644
index 0000000..e762704
--- /dev/null
+++ b/internal/daemon/multiplex_test.go
@@ -0,0 +1,128 @@
+package daemon
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/karnstack/flue/internal/session"
+ "github.com/karnstack/flue/internal/wire"
+)
+
+// TestWelcomeAnnouncesMultiplex pins the capability the web client
+// feature-detects the split and scratch affordances on. Dropping it from the
+// welcome silently hides both from every client, which is why it is a test
+// and not just a literal in conn.go.
+func TestWelcomeAnnouncesMultiplex(t *testing.T) {
+ ts, _ := newTestServer(t)
+ c := dial(t, ts)
+
+ readUntil(t, c, func(msg any, _ []byte) bool {
+ w, ok := msg.(wire.Welcome)
+ if !ok {
+ return false
+ }
+ if !slices.Contains(w.Caps, "multiplex") {
+ t.Fatalf("welcome caps = %v, want to contain %q", w.Caps, "multiplex")
+ }
+ return true
+ })
+}
+
+// TestSpawnCarriesGroupAndEphemeralOverTheWire: the optional spawn fields
+// reach the registry, and the session list hands them back — which is how a
+// group view learns its members and a list knows what to fold away.
+func TestSpawnCarriesGroupAndEphemeralOverTheWire(t *testing.T) {
+ ts, reg := newTestServer(t)
+ anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ defer anchor.Close()
+
+ c := dial(t, ts)
+ writeControl(t, c, wire.Hello{Ver: "test"})
+ writeControl(t, c, wire.Spawn{
+ Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24,
+ Group: anchor.ID(), Ephemeral: true, ReqID: 5,
+ })
+
+ var spawned string
+ readUntil(t, c, func(msg any, _ []byte) bool {
+ a, ok := msg.(wire.Attached)
+ if !ok || a.ReqID != 5 {
+ return false
+ }
+ spawned = a.ID
+ return true
+ })
+
+ s, ok := reg.Get(spawned)
+ if !ok {
+ t.Fatalf("spawned session %q not in the registry", spawned)
+ }
+ defer s.Close()
+ info := s.Info()
+ if info.Group != anchor.ID() {
+ t.Errorf("Group = %q, want %q", info.Group, anchor.ID())
+ }
+ if !info.Ephemeral {
+ t.Error("Ephemeral = false, want true")
+ }
+
+ // And the list reports what the spawn declared.
+ writeControl(t, c, wire.List{})
+ readUntil(t, c, func(msg any, _ []byte) bool {
+ l, ok := msg.(wire.Sessions)
+ if !ok {
+ return false
+ }
+ for _, row := range l.Sessions {
+ if row.ID == spawned {
+ if row.Group != anchor.ID() || !row.Ephemeral {
+ t.Errorf("listed row group=%q ephemeral=%v, want %q true",
+ row.Group, row.Ephemeral, anchor.ID())
+ }
+ return true
+ }
+ }
+ return false
+ })
+}
+
+// TestUpdateClearsEphemeralOverTheWire is the wire half of the keep
+// affordance: an update carrying ephemeral=false promotes the scratch, and
+// one carrying no ephemeral field leaves the flag alone.
+func TestUpdateClearsEphemeralOverTheWire(t *testing.T) {
+ ts, reg := newTestServer(t)
+ anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ defer anchor.Close()
+ scratch, err := reg.Spawn(session.SpawnOpts{
+ Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24,
+ Group: anchor.ID(), Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ defer scratch.Close()
+
+ c := dial(t, ts)
+ writeControl(t, c, wire.Hello{Ver: "test"})
+
+ // An edit about something else must not touch the flag.
+ name := "kept name"
+ writeControl(t, c, wire.Update{ID: scratch.ID(), Name: &name})
+ readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok })
+ if info := scratch.Info(); !info.Ephemeral || info.Name != name {
+ t.Fatalf("after a name edit: ephemeral=%v name=%q, want true %q", info.Ephemeral, info.Name, name)
+ }
+
+ kept := false
+ writeControl(t, c, wire.Update{ID: scratch.ID(), Ephemeral: &kept})
+ readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok })
+ if info := scratch.Info(); info.Ephemeral {
+ t.Fatal("Ephemeral still true after the clearing update")
+ }
+}
diff --git a/internal/session/multiplex_test.go b/internal/session/multiplex_test.go
new file mode 100644
index 0000000..b6e2350
--- /dev/null
+++ b/internal/session/multiplex_test.go
@@ -0,0 +1,210 @@
+package session
+
+import (
+ "testing"
+ "time"
+)
+
+// TestSpawnCarriesGroupAndEphemeral pins the two new SpawnOpts fields onto
+// Info, and — the half that guards every session that exists today — that a
+// spawn naming neither reports neither.
+func TestSpawnCarriesGroupAndEphemeral(t *testing.T) {
+ r := NewRegistry(nil)
+ anchor := spawnRunning(t, r)
+
+ member, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24,
+ Group: anchor.ID(), Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = member.Close() })
+
+ info := member.Info()
+ if info.Group != anchor.ID() {
+ t.Errorf("Group = %q, want %q", info.Group, anchor.ID())
+ }
+ if !info.Ephemeral {
+ t.Error("Ephemeral = false, want true")
+ }
+
+ plain := anchor.Info()
+ if plain.Group != "" || plain.Ephemeral {
+ t.Errorf("plain session carries group=%q ephemeral=%v, want neither", plain.Group, plain.Ephemeral)
+ }
+}
+
+// TestEphemeralExitedReapedFast pins the retention split: an exited scratch
+// terminal leaves the registry after EphemeralRetention, while an ordinary
+// exited session beside it waits out the full ExitedRetention.
+func TestEphemeralExitedReapedFast(t *testing.T) {
+ now := time.Now()
+ r := NewRegistry(func() time.Time { return now })
+
+ plain, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ scratch, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24, Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ waitExited(t, plain, 5*time.Second)
+ waitExited(t, scratch, 5*time.Second)
+
+ now = now.Add(EphemeralRetention + time.Second)
+ r.Reap()
+ if _, ok := r.Get(scratch.ID()); ok {
+ t.Error("ephemeral session still present past EphemeralRetention")
+ }
+ if _, ok := r.Get(plain.ID()); !ok {
+ t.Error("ordinary session reaped on the ephemeral schedule")
+ }
+}
+
+// TestRunningEphemeralFollowsItsParent is the scratch terminal's lifecycle
+// promise: dismissed or not, it runs while its parent runs — the first Reap
+// with both alive touches nothing — and it is closed by the sweep once the
+// parent has exited, without waiting for the parent to be reaped.
+func TestRunningEphemeralFollowsItsParent(t *testing.T) {
+ now := time.Now()
+ r := NewRegistry(func() time.Time { return now })
+
+ parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sleep", "0.2"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = parent.Close() })
+ scratch, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24,
+ Group: parent.ID(), Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = scratch.Close() })
+
+ // Both alive: the sweep must leave the scratch running.
+ r.Reap()
+ if scratch.Info().State != "running" {
+ t.Fatal("scratch closed while its parent was still running")
+ }
+
+ waitExited(t, parent, 5*time.Second)
+ // The parent is exited but not yet reaped — still listable in its
+ // retention window — and that alone ends the scratch.
+ r.Reap()
+ waitExited(t, scratch, 5*time.Second)
+}
+
+// TestRunningEphemeralWithoutAParentIsLeftAlone: no group means no lifecycle
+// to follow, and the sweep must not guess one.
+func TestRunningEphemeralWithoutAParentIsLeftAlone(t *testing.T) {
+ r := NewRegistry(nil)
+ scratch, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24, Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = scratch.Close() })
+
+ r.Reap()
+ if scratch.Info().State != "running" {
+ t.Fatal("ungrouped ephemeral session closed by the sweep")
+ }
+}
+
+// TestApplyMetaClearsEphemeral is the "keep" affordance: clearing the flag
+// promotes a scratch to an ordinary member, after which the parent's exit no
+// longer takes it down.
+func TestApplyMetaClearsEphemeral(t *testing.T) {
+ now := time.Now()
+ r := NewRegistry(func() time.Time { return now })
+
+ parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = parent.Close() })
+ scratch, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24,
+ Group: parent.ID(), Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = scratch.Close() })
+
+ kept := false
+ info, err := r.UpdateMeta(scratch.ID(), MetaPatch{Ephemeral: &kept})
+ if err != nil {
+ t.Fatalf("UpdateMeta: %v", err)
+ }
+ if info.Ephemeral {
+ t.Fatal("Ephemeral still true after the clearing patch")
+ }
+ if info.Group != parent.ID() {
+ t.Errorf("Group = %q after the patch, want %q untouched", info.Group, parent.ID())
+ }
+
+ waitExited(t, parent, 5*time.Second)
+ r.Reap()
+ if scratch.Info().State != "running" {
+ t.Fatal("a kept session was closed with its parent; promotion did not stick")
+ }
+}
+
+// TestSnapshotSkipsEphemeralAndCarriesGroup pins the restart story: a split
+// member revives as a member because its snapshot names the group, and a
+// scratch terminal is not snapshotted at all — its life is bound to a process
+// the restart does not preserve.
+func TestSnapshotSkipsEphemeralAndCarriesGroup(t *testing.T) {
+ r := NewRegistry(nil)
+ anchor := spawnRunning(t, r)
+
+ member, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24, Group: anchor.ID(),
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = member.Close() })
+ scratch, err := r.Spawn(SpawnOpts{
+ Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24,
+ Group: anchor.ID(), Ephemeral: true,
+ })
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ t.Cleanup(func() { _ = scratch.Close() })
+
+ if _, ok := scratch.Snapshot(); ok {
+ t.Error("an ephemeral session produced a snapshot")
+ }
+ snap, ok := member.Snapshot()
+ if !ok {
+ t.Fatal("a grouped member produced no snapshot")
+ }
+ if snap.Group != anchor.ID() {
+ t.Fatalf("snapshot Group = %q, want %q", snap.Group, anchor.ID())
+ }
+
+ // And the revival hands the link back.
+ _ = member.Close()
+ r2 := NewRegistry(nil)
+ revived, err := r2.Revive(snap)
+ if err != nil {
+ t.Fatalf("Revive: %v", err)
+ }
+ t.Cleanup(func() { _ = revived.Close() })
+ if got := revived.Info().Group; got != anchor.ID() {
+ t.Errorf("revived Group = %q, want %q", got, anchor.ID())
+ }
+ if revived.Info().Ephemeral {
+ t.Error("revived session reports Ephemeral = true")
+ }
+}
diff --git a/internal/session/registry.go b/internal/session/registry.go
index d4a92c2..118ba97 100644
--- a/internal/session/registry.go
+++ b/internal/session/registry.go
@@ -252,8 +252,19 @@ func (r *Registry) start(opts SpawnOpts, id string, preload []byte, restore Info
Tags: normalizeTags(restore.Tags),
CreatedAt: created,
LastActive: born,
+ // From the options, then from the restore record: a spawn names its
+ // group up front, and a revival hands back the one the dead session
+ // carried. Both empty is every session from before the field.
+ Group: opts.Group,
+ // Deliberately not restored: an ephemeral session is never
+ // snapshotted (see Session.Snapshot), so a restore record cannot
+ // carry the flag.
+ Ephemeral: opts.Ephemeral,
},
}
+ if s.info.Group == "" {
+ s.info.Group = restore.Group
+ }
s.info.ID = s.id
s.info.Title = restore.Title
// Before pump starts, so everything restored precedes everything live.
@@ -381,25 +392,61 @@ func (r *Registry) List() []*Session {
return out
}
-// Reap removes sessions that exited more than ExitedRetention ago.
+// Reap removes sessions that exited more than their retention ago —
+// ExitedRetention ordinarily, EphemeralRetention for a scratch terminal —
+// and closes the running ephemeral children of parents that have ended.
+//
+// The second half is the whole of an ephemeral session's lifecycle: a scratch
+// terminal is dismissed by detaching, never by closing, so the shell inside it
+// runs on — a dev server started there keeps serving — until the session it
+// was opened from exits or is reaped. This sweep is where that promise is
+// kept. A parent that is merely exited (still listable in its retention
+// window) already ends its scratch: the terminal the scratch belongs beside is
+// over, and nothing can reopen it from there.
//
// Victims are collected under r.mu and closed only after it has been
// released. Close signals a process group, waits for the session's supervisor
// to answer and closes a file descriptor; doing any of that while holding r.mu
// would turn a stall in one session into a stall of Get, List, Spawn and every
-// other session too. The one session call made under r.mu, exitStatus, reads
-// two fields under s.mu and returns.
+// other session too. The session calls made under r.mu, exitStatus and
+// groupID, read fields under s.mu and return.
func (r *Registry) Reap() {
now := r.clock()
var victims []*Session
+ var orphans []*Session
r.mu.Lock()
for id, s := range r.sessions {
- exited, at := s.exitStatus()
- if exited && now.Sub(at) >= ExitedRetention {
+ exited, at, ephemeral := s.exitStatus()
+ retention := ExitedRetention
+ if ephemeral {
+ // A scratch terminal's final output has one reader, who has already
+ // dismissed it. Keeping it listable for ten minutes would pile
+ // hidden exited rows behind every list that folds them away.
+ retention = EphemeralRetention
+ }
+ if exited && now.Sub(at) >= retention {
victims = append(victims, s)
delete(r.sessions, id)
+ continue
}
+ if !ephemeral || exited {
+ continue
+ }
+ // A running scratch terminal lives exactly as long as its parent. An
+ // ephemeral session with no group has no parent to follow and is left
+ // alone — its client owns its lifecycle.
+ group := s.groupID()
+ if group == "" {
+ continue
+ }
+ parent, held := r.sessions[group]
+ if held {
+ if parentExited, _, _ := parent.exitStatus(); !parentExited {
+ continue
+ }
+ }
+ orphans = append(orphans, s)
}
r.mu.Unlock()
@@ -412,4 +459,19 @@ func (r *Registry) Reap() {
// a name is exactly what makes it findable in that window.
DeleteMeta(dir, s.ID())
}
+ for _, s := range orphans {
+ // Between collection under r.mu and this Close, a Keep may have
+ // landed: UpdateMeta clears Ephemeral without r.mu, and a session
+ // promoted in that window is an ordinary member now — killing it
+ // would be the sweep spending a decision the user just reversed.
+ // Re-read the flag at the last moment; the promotion path never sets
+ // it back, so a stale read here can only spare, never kill.
+ if _, _, ephemeral := s.exitStatus(); !ephemeral {
+ continue
+ }
+ // Close, not delete: the kill lands now, the exit is recorded by the
+ // session's own supervisor, and the next sweep reaps the row through
+ // the ordinary path above.
+ _ = s.Close()
+ }
}
diff --git a/internal/session/session.go b/internal/session/session.go
index ac6577a..5b0a390 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -20,6 +20,13 @@ import (
// output remains readable before the registry reaps it.
const ExitedRetention = 10 * time.Minute
+// EphemeralRetention is ExitedRetention for a session marked ephemeral: long
+// enough for the client that owns it to hear the exit, and no longer. It is
+// not an expiry on a running scratch terminal — a dismissed scratch keeps
+// running until its parent session ends (see Registry.Reap) — it only keeps
+// exited ones from waiting out ten minutes hidden from every list.
+const EphemeralRetention = 10 * time.Second
+
// DefaultRingSize is the default scrollback capacity per session.
const DefaultRingSize = 2 << 20 // 2 MiB
@@ -51,6 +58,21 @@ type SpawnOpts struct {
Cols uint16
Rows uint16
RingSize int // zero means DefaultRingSize
+
+ // Group is the id of the session this one is grouped under — the anchor a
+ // client renders it beside as a split or a tab. It is metadata and nothing
+ // more: the daemon never resolves it, never requires the anchor to exist,
+ // and never treats members differently. Empty is every session spawned
+ // before the field existed, and every session that stands alone.
+ Group string
+ // Ephemeral marks a session a client considers disposable — a scratch
+ // terminal, spawned with Group naming the session it was opened from. Its
+ // life is tied to that parent: dismissing the scratch UI merely detaches,
+ // and the shell runs on until the parent session ends, at which point the
+ // registry closes it (see Reap). Server-side it is otherwise only the
+ // shorter exited retention; whether to hide it from a list is a client
+ // decision.
+ Ephemeral bool
}
// Info is a snapshot of session state safe to serialise.
@@ -79,6 +101,10 @@ type Info struct {
Rows uint16 `json:"rows"`
CreatedAt time.Time `json:"createdAt"`
LastActive time.Time `json:"lastActive"`
+ // Group and Ephemeral mirror SpawnOpts; see there. Both omitempty, so a
+ // session that carries neither serialises exactly as it always has.
+ Group string `json:"group,omitempty"`
+ Ephemeral bool `json:"ephemeral,omitempty"`
}
// MetaPatch is a partial update to a session's human-owned metadata: a nil
@@ -93,6 +119,10 @@ type MetaPatch struct {
Name *string
Tags *[]string
Pinned *bool
+ // Ephemeral is here for exactly one edit: a scratch terminal being kept.
+ // Clearing the flag promotes it to an ordinary session — listable by the
+ // client's rules and back on the ordinary exited retention.
+ Ephemeral *bool
}
// Sub is one subscriber's view of a session's output stream. Backlog plus
@@ -212,6 +242,15 @@ type Session struct {
func (s *Session) ID() string { return s.id }
+// groupID reads the session's group link under s.mu. It is set at spawn and
+// never rewritten, but the lock keeps the read on the right side of the rule
+// rather than leaning on that.
+func (s *Session) groupID() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.info.Group
+}
+
// Info returns a snapshot of the session's state, and is also where the
// child's cwd is refreshed — the kernel is the only party that knows where a
// `cd` left the shell, so every snapshot asks it.
@@ -259,6 +298,9 @@ func (s *Session) ApplyMeta(p MetaPatch) Info {
if p.Pinned != nil {
s.info.Pinned = *p.Pinned
}
+ if p.Ephemeral != nil {
+ s.info.Ephemeral = *p.Ephemeral
+ }
return s.info
}
@@ -871,13 +913,14 @@ func masterReadable(f *os.File) bool {
return readable
}
-// exitStatus reports whether the child has exited and, if so, when — the two
-// fields Registry.Reap needs. It is a plain read of two fields under s.mu,
-// which is never held across anything that can block.
-func (s *Session) exitStatus() (bool, time.Time) {
+// exitStatus reports whether the child has exited, when, and whether the
+// session is ephemeral — the fields Registry.Reap needs to pick a retention.
+// It is a plain read of fields under s.mu, which is never held across
+// anything that can block.
+func (s *Session) exitStatus() (exited bool, at time.Time, ephemeral bool) {
s.mu.Lock()
defer s.mu.Unlock()
- return s.info.State == "exited", s.exitedAt
+ return s.info.State == "exited", s.exitedAt, s.info.Ephemeral
}
// signalGroup delivers sig to the process group led by the child. A group that
diff --git a/internal/session/snapshot.go b/internal/session/snapshot.go
index 5062046..5ff30c3 100644
--- a/internal/session/snapshot.go
+++ b/internal/session/snapshot.go
@@ -36,7 +36,13 @@ type Snapshot struct {
Name string `json:"name"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
- Cwd string `json:"cwd"`
+ // Group travels so a split survives a restart as a split: members are just
+ // sessions, and this is the one fact that makes them members. omitempty
+ // keeps the ungrouped snapshot byte-compatible with what earlier daemons
+ // wrote and read. There is no Ephemeral beside it, because an ephemeral
+ // session is never snapshotted at all — see Session.Snapshot.
+ Group string `json:"group,omitempty"`
+ Cwd string `json:"cwd"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
// The ring's retained bytes. encoding/json carries []byte as base64.
@@ -133,11 +139,14 @@ func reviveNote(claudeSession string) []byte {
}
// Snapshot captures what a revival needs. ok is false for an exited or
-// closed session: those end with the daemon rather than coming back.
+// closed session — those end with the daemon rather than coming back — and
+// for an ephemeral one: a scratch terminal's life is bound to its parent's
+// process, and the parent's revival is a fresh shell the old scratch has no
+// standing beside.
func (s *Session) Snapshot() (Snapshot, bool) {
s.mu.Lock()
defer s.mu.Unlock()
- if s.closed || s.info.State != "running" {
+ if s.closed || s.info.State != "running" || s.info.Ephemeral {
return Snapshot{}, false
}
ring, _ := s.ring.Since(s.ring.BaseSeq()) // a fresh copy, per Since
@@ -148,6 +157,7 @@ func (s *Session) Snapshot() (Snapshot, bool) {
Name: s.info.Name,
Tags: s.info.Tags,
Pinned: s.info.Pinned,
+ Group: s.info.Group,
Cwd: s.info.Cwd,
Cols: s.info.Cols,
Rows: s.info.Rows,
@@ -210,6 +220,7 @@ func (r *Registry) Revive(snap Snapshot) (*Session, error) {
Name: snap.Name,
Tags: snap.Tags,
Pinned: snap.Pinned,
+ Group: snap.Group,
CreatedAt: snap.CreatedAt,
},
)
diff --git a/internal/transport/relay/channel.go b/internal/transport/relay/channel.go
index 1df2e49..8e8f1e0 100644
--- a/internal/transport/relay/channel.go
+++ b/internal/transport/relay/channel.go
@@ -309,7 +309,11 @@ func (t *Transport) openChannel(s *socket, m *relaywire.Open) {
// everything, and there is nobody left to answer.
return
}
- go t.serveChannel(s, ch, m.Origin)
+ t.serving.Add(1)
+ go func() {
+ defer t.serving.Done()
+ t.serveChannel(s, ch, m.Origin)
+ }()
}
// canServeChannels reports whether this daemon has what a channel needs: a
@@ -644,7 +648,9 @@ func (t *Transport) pair(s *socket, m *relaywire.Pair) {
// path parses; decoding the control message already copied it out of the
// read buffer.
body := m.Body
+ t.serving.Add(1)
go func() {
+ defer t.serving.Done()
defer func() { <-t.pairings }()
t.answerPair(s, m.ID, t.srv.PairDevice(body, relayPeer))
}()
diff --git a/internal/transport/relay/channel_test.go b/internal/transport/relay/channel_test.go
index 327a939..fdba832 100644
--- a/internal/transport/relay/channel_test.go
+++ b/internal/transport/relay/channel_test.go
@@ -158,7 +158,14 @@ func (s *readingServer) ServeConn(ctx context.Context, mc daemon.MessageConn, me
}()
if s.block != nil {
- <-s.block
+ // As the real ServeConn would: blocked or not, the context ending is
+ // the end of the connection. Run joins these goroutines on its way
+ // out now, so a block that ignored ctx would deadlock every teardown
+ // whose cleanup closes the block after stopping the transport.
+ select {
+ case <-s.block:
+ case <-ctx.Done():
+ }
return
}
for {
@@ -891,6 +898,92 @@ func TestRelayChannelBackpressureClosesOneChannelNotTheSocket(t *testing.T) {
attach(t, c, 2, id.deviceKey, id.key.Public)
}
+// laggingServer is a Server whose ServeConn keeps working past its context —
+// the shape of a connection's tail writes (a last-seen stamp, a back-filled
+// certificate) landing after Run was told to stop. It deliberately ignores
+// ctx: the real daemon honours it, but honouring it is exactly what would
+// hide a Run that returns without waiting.
+type laggingServer struct {
+ release chan struct{}
+
+ mu sync.Mutex
+ served int
+}
+
+func (s *laggingServer) ServeConn(_ context.Context, mc daemon.MessageConn, _ daemon.ConnMeta) {
+ s.mu.Lock()
+ s.served++
+ s.mu.Unlock()
+ <-s.release
+ _ = mc.Close()
+}
+
+func (s *laggingServer) PairDevice([]byte, string) daemon.PairOutcome {
+ return daemon.PairRefusal()
+}
+
+func (s *laggingServer) SetRelayStatus(string, string) {}
+
+func (s *laggingServer) waitServed(t *testing.T) {
+ t.Helper()
+ deadline := time.Now().Add(waitFor)
+ for {
+ s.mu.Lock()
+ n := s.served
+ s.mu.Unlock()
+ if n >= 1 {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("no connection was handed to ServeConn")
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+// TestRunWaitsForServedConnections: Run's return is the transport's promise
+// that nothing of it is still running. The goroutines serving channels write
+// into the device registry (a last-seen stamp on every attach), so a Run that
+// returns while one is still going leaves those writes racing whatever the
+// caller does next — in the daemon that is shutdown, in these tests it is the
+// harness deleting the registry's directory out from under the write.
+func TestRunWaitsForServedConnections(t *testing.T) {
+ t.Parallel()
+ r := newFakeRelay(t, "s")
+ id := newIdentity(t)
+ srv := &laggingServer{release: make(chan struct{})}
+ var releaseOnce sync.Once
+ release := func() { releaseOnce.Do(func() { close(srv.release) }) }
+ t.Cleanup(release)
+
+ tr := newChannelTransport(t, r, srv, id, nil)
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ done := make(chan error, 1)
+ go func() { done <- tr.Run(ctx) }()
+
+ c := r.accept(t)
+ attach(t, c, 1, id.deviceKey, id.key.Public)
+ srv.waitServed(t)
+
+ cancel()
+ select {
+ case <-done:
+ t.Fatal("Run returned while a connection was still being served")
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ release()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Errorf("Run returned %v after its context was cancelled, want nil", err)
+ }
+ case <-time.After(waitFor):
+ t.Fatal("Run did not return after the last served connection ended")
+ }
+}
+
// panickingServer stands in for any unhandled failure on the serve path.
// daemon.ServeConn propagates a panic to its caller by design; on loopback that
// caller is net/http, which recovers per connection.
@@ -1146,9 +1239,12 @@ func TestRelayPairingRunsOffTheReadLoop(t *testing.T) {
<-release
return daemon.PairRefusal()
}}
- t.Cleanup(func() { close(release) })
tr := newChannelTransport(t, r, srv, id, nil)
runTransport(t, tr)
+ // After runTransport, deliberately: cleanups run last-first, and Run now
+ // waits for the parked ceremony on its way out, so the park must be
+ // released before the transport is stopped.
+ t.Cleanup(func() { close(release) })
c := r.accept(t)
c.sendControl(t, relaywire.Pair{ID: 1, Origin: testOrigin, Body: pairingBody(t, "t", unpairedKey(t).Public)})
diff --git a/internal/transport/relay/relay.go b/internal/transport/relay/relay.go
index f15332d..ef3503a 100644
--- a/internal/transport/relay/relay.go
+++ b/internal/transport/relay/relay.go
@@ -255,6 +255,15 @@ type Transport struct {
// count, taken by each and returned when it finishes. See maxPairings.
pairings chan struct{}
+ // serving counts the goroutines this transport has spun off to serve a
+ // channel or answer a pairing — the ones that write into the device
+ // registry (a last-seen stamp, a pairing's new entry). Run waits for it
+ // on the way out, and only there: within a reconnect a lame connection's
+ // unwinding must not delay the dial that brings every other browser back,
+ // but Run's return is the promise that nothing of the transport is still
+ // running — least of all a registry write racing the caller's teardown.
+ serving sync.WaitGroup
+
log *slog.Logger
// keepalive is the interval between flue-ping frames, a field rather than a
@@ -352,6 +361,10 @@ func (t *Transport) Run(ctx context.Context) error {
// context is done, and a status left reading "connecting" would have every
// welcome after it announce a relay nothing is trying to reach.
defer t.srv.SetRelayStatus(daemon.RelayOff, "")
+ // LIFO with the line above: the serve goroutines are joined first, then
+ // the status flips. See the field's comment for why the join is here and
+ // not in each reconnect iteration.
+ defer t.serving.Wait()
attempt := 0
for {
diff --git a/internal/wire/control.go b/internal/wire/control.go
index f7ffbf9..dc64cb0 100644
--- a/internal/wire/control.go
+++ b/internal/wire/control.go
@@ -24,6 +24,14 @@ type Spawn struct {
// ReqID correlates this request with the attached or error answering it.
// Client-chosen; zero means the client asked for no correlation.
ReqID uint64 `json:"reqId,omitempty"`
+ // Group links the new session under an anchor session — a split pane, or a
+ // tab in the anchor's group. Optional and additive: a daemon from before
+ // the field ignores it, and the session simply spawns ungrouped.
+ Group string `json:"group,omitempty"`
+ // Ephemeral marks a scratch terminal: hidden by clients, reaped fast once
+ // exited, and closed by the daemon when the Group parent ends. Optional
+ // and additive like Group.
+ Ephemeral bool `json:"ephemeral,omitempty"`
}
type Attach struct {
@@ -97,6 +105,10 @@ type Update struct {
Name *string `json:"name,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Pinned *bool `json:"pinned,omitempty"`
+ // Ephemeral exists for one edit: clearing it keeps a scratch terminal,
+ // promoting it to an ordinary member of its group. A pointer for the same
+ // reason the others are — absent means this edit is not about it.
+ Ephemeral *bool `json:"ephemeral,omitempty"`
}
// Peek asks for the tail of a session's scrollback without attaching to it.
diff --git a/spec/protocol.md b/spec/protocol.md
index 236a985..a4de277 100644
--- a/spec/protocol.md
+++ b/spec/protocol.md
@@ -29,13 +29,13 @@ Every control message is a JSON object with a `type` discriminator.
|---|---|---|
| `hello` | `ver`, `caps[]` | open the conversation |
| `list` | — | list the daemon's sessions |
-| `spawn` | `cwd`, `cmd[]`, `cols`, `rows`, `reqId?` | start a session and attach to it |
+| `spawn` | `cwd`, `cmd[]`, `cols`, `rows`, `reqId?`, `group?`, `ephemeral?` | start a session and attach to it |
| `attach` | `id`, `lastSeq`, `reqId?` | attach to an existing session |
| `detach` | `ref` | release an attachment |
| `resize` | `ref`, `cols`, `rows`, `primary` | report this view's dimensions |
| `signal` | `ref`, `sig` | send a signal to the session's process |
| `close` | `ref` *or* `id` | end the session |
-| `update` | `id`, `name?`, `tags[]?`, `pinned?` | edit a session's human-owned metadata |
+| `update` | `id`, `name?`, `tags[]?`, `pinned?`, `ephemeral?` | edit a session's human-owned metadata |
| `peek` | `id`, `bytes?`, `reqId?` | read the tail of a session's scrollback without attaching |
| `stat` | `id`, `paths[]`, `reqId?` | ask whether paths exist, relative to a session |
| `read` | `id`, `path`, `reqId?` | start reading one |
@@ -92,6 +92,47 @@ Each record of `deviceList.devices[]` carries `id`, `label`, `pairedAt` and
`lastSeen`. Both timestamps are unix **seconds**, not the RFC 3339 strings
`sessions[]` uses.
+### Groups and ephemeral sessions
+
+Both fields are **optional and additive**, and a plain session — no `group`,
+no `ephemeral` — behaves exactly as it always has. An old daemon ignores the
+unknown spawn fields and the session simply starts ungrouped; an old client
+ignores the unknown `sessions[]` fields and grouped or ephemeral sessions
+show up as ordinary rows. `welcome.caps` containing `"multiplex"` is how a
+client learns the daemon understands them; without it a client should hide
+its split and scratch affordances rather than spawn sessions whose fields
+silently dropped.
+
+`spawn.group` links the new session under an anchor session — the daemon
+records the id on the session's `sessions[]` row and does nothing else with
+it. It never resolves the anchor, never requires it to exist, and never
+treats members differently: grouping is a rendering instruction to clients
+(splits on a desktop, tabs on a phone), not a server concept. Groups are
+flat — a member's `group` names its anchor, and nothing nests.
+
+`spawn.ephemeral` marks a scratch terminal, and it is the one place the
+daemon does act:
+
+- A **running** ephemeral session whose `group` parent has exited (or been
+ reaped) is closed by the daemon's periodic sweep. That is the whole
+ lifecycle: dismissing a scratch terminal in a client is a detach, never a
+ close, so the shell inside it — a dev server, a watch loop — keeps running
+ until the session it was opened from ends.
+- An **exited** ephemeral session is reaped after seconds rather than the
+ usual ten minutes, so hidden scratch rows do not pile up behind lists that
+ fold them away.
+- A running ephemeral session with **no** `group` is left entirely alone.
+
+`update.ephemeral` exists for one edit: `false` clears the flag and keeps the
+scratch — an ordinary member of its group from then on, on ordinary
+retention, no longer bound to its parent. Ephemeral sessions are never
+snapshotted for revival; grouped non-ephemeral sessions revive with their
+`group` intact.
+
+Whether to hide ephemeral sessions or fold a group into one row is a client
+decision. The read-only `GET /api/sessions` and the CLI keep reporting every
+session, fields included.
+
`welcome.relay`, when present, is `{status, origin?}` — the state of the
daemon's relay leg at the moment this connection was accepted. `status` is
`connecting` while the daemon is dialling, and `connected` once the socket is
diff --git a/testdata/wire/control.json b/testdata/wire/control.json
index dc0c2af..e232a82 100644
--- a/testdata/wire/control.json
+++ b/testdata/wire/control.json
@@ -29,6 +29,29 @@
"reqId": 6
}
},
+ {
+ "name": "spawnScratch",
+ "json": {
+ "type": "spawn",
+ "cwd": "/home/karn/code",
+ "cols": 120,
+ "rows": 40,
+ "reqId": 21,
+ "group": "a1b2c3d4e5f60718",
+ "ephemeral": true
+ }
+ },
+ {
+ "name": "spawnSplit",
+ "json": {
+ "type": "spawn",
+ "cwd": "/home/karn/code",
+ "cols": 120,
+ "rows": 40,
+ "reqId": 22,
+ "group": "a1b2c3d4e5f60718"
+ }
+ },
{
"name": "attach",
"json": {
@@ -97,6 +120,14 @@
"pinned": true
}
},
+ {
+ "name": "updateKeepScratch",
+ "json": {
+ "type": "update",
+ "id": "a1b2c3d4e5f60708",
+ "ephemeral": false
+ }
+ },
{
"name": "updateClearTags",
"json": {
@@ -185,6 +216,18 @@
"ver": "0.1.0"
}
},
+ {
+ "name": "welcomeCaps",
+ "json": {
+ "type": "welcome",
+ "daemonId": "local",
+ "host": "macbook",
+ "ver": "0.1.0",
+ "caps": [
+ "multiplex"
+ ]
+ }
+ },
{
"name": "welcomeRelay",
"json": {
@@ -255,6 +298,26 @@
"rows": 24,
"createdAt": "2026-07-28T07:15:00Z",
"lastActive": "2026-07-28T09:00:00Z"
+ },
+ {
+ "id": "s3",
+ "title": "htop",
+ "name": "",
+ "tags": [],
+ "pinned": false,
+ "cwd": "/home/karn/code",
+ "cmd": [
+ "zsh",
+ "-l"
+ ],
+ "state": "running",
+ "exitCode": 0,
+ "cols": 120,
+ "rows": 40,
+ "createdAt": "2026-07-28T10:00:00Z",
+ "lastActive": "2026-07-28T10:31:00Z",
+ "group": "s1",
+ "ephemeral": true
}
]
}
diff --git a/web/src/client/client.test.ts b/web/src/client/client.test.ts
index 480a4a3..056d3ef 100644
--- a/web/src/client/client.test.ts
+++ b/web/src/client/client.test.ts
@@ -221,6 +221,8 @@ describe('control message golden file', () => {
'hello',
'list',
'spawn',
+ 'spawnScratch',
+ 'spawnSplit',
'attach',
'detach',
'resize',
@@ -229,6 +231,7 @@ describe('control message golden file', () => {
'closeById',
'update',
'updateTagsAndPinned',
+ 'updateKeepScratch',
'updateClearTags',
'updateClearName',
'peek',
@@ -240,6 +243,7 @@ describe('control message golden file', () => {
'pairStart',
'pairCancel',
'welcome',
+ 'welcomeCaps',
'welcomeRelay',
'welcomeRelayConnecting',
'sessions',
@@ -287,6 +291,31 @@ describe('control message golden file', () => {
expect(fixture('spawn')).toStrictEqual(want)
})
+ it('decodes spawn with the scratch fields — group and ephemeral', () => {
+ const want: SpawnMsg = {
+ type: 'spawn',
+ cwd: '/home/karn/code',
+ cols: 120,
+ rows: 40,
+ reqId: 21,
+ group: 'a1b2c3d4e5f60718',
+ ephemeral: true,
+ }
+ expect(fixture('spawnScratch')).toStrictEqual(want)
+ })
+
+ it('decodes spawn with a group alone — a split pane', () => {
+ const want: SpawnMsg = {
+ type: 'spawn',
+ cwd: '/home/karn/code',
+ cols: 120,
+ rows: 40,
+ reqId: 22,
+ group: 'a1b2c3d4e5f60718',
+ }
+ expect(fixture('spawnSplit')).toStrictEqual(want)
+ })
+
it('decodes attach', () => {
const want: AttachMsg = {
type: 'attach',
@@ -392,6 +421,15 @@ describe('control message golden file', () => {
expect(got.pinned).toBe(false)
})
+ it('decodes an update keeping a scratch terminal', () => {
+ // `ephemeral: false` is the promotion — the same explicit-falsy hazard as
+ // clearing a name, and the only edit the field exists for.
+ const want: UpdateMsg = { type: 'update', id: 'a1b2c3d4e5f60708', ephemeral: false }
+ const got = fixture('updateKeepScratch') as UpdateMsg
+ expect(got).toStrictEqual(want)
+ expect(got.ephemeral).toBe(false)
+ })
+
it('decodes devices', () => {
const want: DevicesMsg = { type: 'devices' }
expect(fixture('devices')).toStrictEqual(want)
@@ -419,6 +457,17 @@ describe('control message golden file', () => {
expect(fixture('welcome')).toStrictEqual(want)
})
+ it('decodes welcome with capabilities — how multiplex is feature-detected', () => {
+ const want: Welcome = {
+ type: 'welcome',
+ daemonId: 'local',
+ host: 'macbook',
+ ver: '0.1.0',
+ caps: ['multiplex'],
+ }
+ expect(fixture('welcomeCaps')).toStrictEqual(want)
+ })
+
it('decodes welcome with a live relay', () => {
const want: Welcome = {
type: 'welcome',
@@ -488,6 +537,26 @@ describe('control message golden file', () => {
createdAt: '2026-07-28T07:15:00Z',
lastActive: '2026-07-28T09:00:00Z',
},
+ {
+ // A scratch terminal: grouped under s1 and ephemeral. Both fields
+ // are omitempty on the Go side, which is why the two rows above
+ // must keep decoding without them.
+ id: 's3',
+ title: 'htop',
+ name: '',
+ tags: [],
+ pinned: false,
+ cwd: '/home/karn/code',
+ cmd: ['zsh', '-l'],
+ state: 'running',
+ exitCode: 0,
+ cols: 120,
+ rows: 40,
+ createdAt: '2026-07-28T10:00:00Z',
+ lastActive: '2026-07-28T10:31:00Z',
+ group: 's1',
+ ephemeral: true,
+ },
],
}
expect(fixture('sessions')).toStrictEqual(want)
@@ -1649,6 +1718,46 @@ describe('FlueClient sending', () => {
expect(c.spawn({ cols: 80, rows: 24 })).toBe(1)
})
+ it('carries group and ephemeral on a spawn that names them, and neither otherwise', () => {
+ const { c, sockets } = harness()
+ c.connect()
+ sockets[0]!.open()
+ c.spawn({ cols: 80, rows: 24, group: 'anchor1', ephemeral: true })
+ c.spawn({ cols: 80, rows: 24 })
+
+ const spawns = sockets[0]!.sentControl().filter((m) => m.type === 'spawn')
+ expect(spawns).toStrictEqual([
+ { type: 'spawn', cols: 80, rows: 24, group: 'anchor1', ephemeral: true, reqId: 1 },
+ { type: 'spawn', cols: 80, rows: 24, reqId: 2 },
+ ])
+ })
+
+ it('carries an explicit ephemeral: false on update — the keep edit', () => {
+ const { c, sockets } = harness()
+ c.connect()
+ sockets[0]!.open()
+ c.update({ id: 's1', ephemeral: false })
+ expect(sockets[0]!.sentControl().filter((m) => m.type === 'update')).toStrictEqual([
+ { type: 'update', id: 's1', ephemeral: false },
+ ])
+ })
+
+ it('answers hasCap from the welcome, and not before one', () => {
+ const { c, sockets } = harness()
+ c.connect()
+ sockets[0]!.open()
+ expect(c.hasCap('multiplex')).toBe(false)
+ sockets[0]!.emitControl({
+ type: 'welcome',
+ daemonId: 'local',
+ host: 'h',
+ ver: '1',
+ caps: ['multiplex'],
+ })
+ expect(c.hasCap('multiplex')).toBe(true)
+ expect(c.hasCap('something-else')).toBe(false)
+ })
+
it('drops rather than holds a spawn issued while the socket is down', async () => {
// A held spawn would surface behind a ten-second backoff as a shell
// nobody asked for at a screen nobody is looking at.
diff --git a/web/src/client/client.ts b/web/src/client/client.ts
index 7bfea3f..2a3ea97 100644
--- a/web/src/client/client.ts
+++ b/web/src/client/client.ts
@@ -405,6 +405,19 @@ export class FlueClient {
return this.lastWelcome?.ver ?? null
}
+ /**
+ * Whether the daemon's welcome declared a capability — how a consumer
+ * feature-detects before drawing an affordance. `multiplex` is the one that
+ * exists today: it gates the split and scratch-terminal controls, which
+ * must not spawn sessions whose `group`/`ephemeral` fields an older daemon
+ * would silently drop. False before the first welcome, which errs on the
+ * side of hiding a control for one round trip rather than showing one that
+ * cannot work.
+ */
+ hasCap(cap: string): boolean {
+ return this.lastWelcome?.caps?.includes(cap) ?? false
+ }
+
/**
* Why the daemon revoked this device, or null while it has not —
* `onRevoked`'s counterpart for a consumer that mounts after the event, as
@@ -505,7 +518,13 @@ export class FlueClient {
* to protect. Losing it costs a retry and little else, since the sessions
* screen re-lists on reconnect and the row visibly snaps back.
*/
- update(patch: { id: string; name?: string; tags?: string[]; pinned?: boolean }) {
+ update(patch: {
+ id: string
+ name?: string
+ tags?: string[]
+ pinned?: boolean
+ ephemeral?: boolean
+ }) {
this.send({ type: 'update', ...patch })
}
@@ -624,7 +643,14 @@ export class FlueClient {
* that appears minutes later at a screen nobody is looking at is worse
* than none.
*/
- spawn(opts: { cwd?: string; cmd?: string[]; cols: number; rows: number }): number | null {
+ spawn(opts: {
+ cwd?: string
+ cmd?: string[]
+ cols: number
+ rows: number
+ group?: string
+ ephemeral?: boolean
+ }): number | null {
if (!this.ready || !this.sock) return null
const { cols, rows, ...rest } = opts
const reqId = this.nextReqId++
diff --git a/web/src/client/protocol.ts b/web/src/client/protocol.ts
index a011718..7cac2c7 100644
--- a/web/src/client/protocol.ts
+++ b/web/src/client/protocol.ts
@@ -94,6 +94,19 @@ export interface SessionInfo {
createdAt: string
/** RFC 3339, as Go marshals a time.Time. */
lastActive: string
+ /**
+ * The id of the anchor session this one is grouped under — a split pane on
+ * a desktop, a tab on a phone. Absent for every session that stands alone,
+ * which is every session from before the field. Metadata only: the daemon
+ * records it and nothing else, so folding a group is this side's decision.
+ */
+ group?: string
+ /**
+ * A scratch terminal: hidden by this client's lists, reaped fast once
+ * exited, and closed by the daemon when its `group` parent ends. Dismissing
+ * its UI detaches — the shell keeps running until then. Absent means false.
+ */
+ ephemeral?: boolean
}
/**
@@ -151,6 +164,14 @@ export interface SpawnMsg {
* asked for. Mirrors `reqId,omitempty` on the Go side.
*/
reqId?: number
+ /**
+ * Group the new session under an anchor session — a split, or a tab in the
+ * anchor's group. Only sent to a daemon whose welcome caps include
+ * `multiplex`; an older daemon would ignore it and spawn an orphan.
+ */
+ group?: string
+ /** Mark a scratch terminal. Same caps gate as `group`. */
+ ephemeral?: boolean
}
export interface AttachMsg {
@@ -234,6 +255,12 @@ export interface UpdateMsg {
name?: string
tags?: string[]
pinned?: boolean
+ /**
+ * One edit only: `false` keeps a scratch terminal, promoting it to an
+ * ordinary member of its group. Absent leaves the flag alone, like every
+ * other field here.
+ */
+ ephemeral?: boolean
}
/**
diff --git a/web/src/components/exit-overlay.tsx b/web/src/components/exit-overlay.tsx
deleted file mode 100644
index 1255208..0000000
--- a/web/src/components/exit-overlay.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Button } from '@/components/ui/button'
-import { cn } from '@/lib/utils'
-
-/**
- * The card shown over a session whose shell has exited.
- *
- * The wrapper is pointer-transparent on purpose: the dimmed scrollback under
- * it stays scrollable and selectable, and only the card itself takes events.
- * Dressed in the --chip-* variables the pane above it carries, so the card
- * wears the terminal's own theme like every other floating control.
- */
-export function ExitOverlay({
- code,
- onRestart,
- onClose,
-}: {
- code: number
- onRestart: () => void
- onClose: () => void
-}) {
- return (
-
-
-
- shell exited{' '}
- ({code})
-
-
-
-
-
-
-
- )
-}
diff --git a/web/src/components/session-group.test.tsx b/web/src/components/session-group.test.tsx
new file mode 100644
index 0000000..c3f5f46
--- /dev/null
+++ b/web/src/components/session-group.test.tsx
@@ -0,0 +1,196 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { PaneTree } from '@/sessions/pane-tree'
+import { SessionGroup } from './session-group'
+
+type Props = Parameters[0]
+
+/**
+ * useIsMobile reads window.innerWidth once at mount and then listens to a
+ * media query; jsdom's default width is desktop-shaped, so mobile is opted
+ * into per test by shrinking the window before render.
+ */
+function setWidth(px: number) {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: px })
+}
+
+const AB_ROW: PaneTree = { split: 'row', ratio: 0.5, a: { leaf: 'a' }, b: { leaf: 'b' } }
+
+function renderGroup(over: Partial = {}) {
+ const props: Props = {
+ tabs: [AB_ROW],
+ panes: [
+ { id: 'a', label: 'api server' },
+ { id: 'b', label: 'logs' },
+ ],
+ onRatio: vi.fn(),
+ active: 'a',
+ onActivate: vi.fn(),
+ renderPane: (id, inset, fit) => (
+
+ ),
+ ...over,
+ }
+ const view = render()
+ return { ...view, props }
+}
+
+afterEach(() => {
+ setWidth(1024)
+ localStorage.clear()
+})
+
+describe('SessionGroup', () => {
+ it('renders a group of one as nothing but the pane — the degenerate case', () => {
+ renderGroup({ panes: [{ id: 'solo', label: '' }], tabs: [{ leaf: 'solo' }] })
+ expect(screen.getByText('pane:solo')).toBeTruthy()
+ expect(screen.queryByRole('tablist')).toBeNull()
+ expect(screen.queryByRole('separator')).toBeNull()
+ expect(screen.getByText('pane:solo').dataset.inset).toBe('0')
+ })
+
+ it('renders one tab of splits with a divider and no strip on a desktop', () => {
+ setWidth(1280)
+ renderGroup()
+ expect(screen.getByText('pane:a')).toBeTruthy()
+ expect(screen.getByText('pane:b')).toBeTruthy()
+ expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('vertical')
+ expect(screen.queryByRole('tablist')).toBeNull()
+ // Split panes must not pin themselves to the visual viewport — the
+ // pinning slots are single-occupancy, and siblings would steal them
+ // from each other. See renderPane's `fit` on the props.
+ expect(screen.getByText('pane:a').dataset.fit).toBe('false')
+ expect(screen.getByText('pane:b').dataset.fit).toBe('false')
+ })
+
+ it('renders a stacked split with a horizontal divider', () => {
+ setWidth(1280)
+ renderGroup({ tabs: [{ split: 'column', ratio: 0.5, a: { leaf: 'a' }, b: { leaf: 'b' } }] })
+ expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('horizontal')
+ })
+
+ it('renders a nested tree — a column stacked inside one side of a row', () => {
+ setWidth(1280)
+ renderGroup({
+ panes: [
+ { id: 'a', label: '' },
+ { id: 'b', label: '' },
+ { id: 'c', label: '' },
+ ],
+ tabs: [
+ {
+ split: 'row',
+ ratio: 0.5,
+ a: { leaf: 'a' },
+ b: { split: 'column', ratio: 0.5, a: { leaf: 'b' }, b: { leaf: 'c' } },
+ },
+ ],
+ })
+ expect(screen.getByText('pane:a')).toBeTruthy()
+ expect(screen.getByText('pane:b')).toBeTruthy()
+ expect(screen.getByText('pane:c')).toBeTruthy()
+ const separators = screen.getAllByRole('separator')
+ expect(separators.map((s) => s.getAttribute('aria-orientation')).sort()).toEqual([
+ 'horizontal',
+ 'vertical',
+ ])
+ })
+
+ it('renders a strip when there is more than one tab, splits inside the active one', () => {
+ setWidth(1280)
+ renderGroup({
+ panes: [
+ { id: 'a', label: 'api server' },
+ { id: 'b', label: 'logs' },
+ { id: 'c', label: 'scratchpad' },
+ ],
+ tabs: [AB_ROW, { leaf: 'c' }],
+ active: 'a',
+ })
+ expect(screen.getByRole('tablist')).toBeTruthy()
+ // The active tab holds the split pair; the other tab's pane stays
+ // unmounted. The multi-pane tab's label carries its pane count.
+ expect(screen.getByText('pane:a')).toBeTruthy()
+ expect(screen.getByText('pane:b')).toBeTruthy()
+ expect(screen.queryByText('pane:c')).toBeNull()
+ expect(screen.getByRole('tab', { name: 'api server · 2' })).toBeTruthy()
+ expect(screen.getByRole('tab', { name: 'scratchpad' })).toBeTruthy()
+ })
+
+ it('shows the tab holding the active pane, and a lone tab pane pins under the strip', () => {
+ setWidth(1280)
+ renderGroup({
+ panes: [
+ { id: 'a', label: '' },
+ { id: 'b', label: '' },
+ { id: 'c', label: '' },
+ ],
+ tabs: [AB_ROW, { leaf: 'c' }],
+ active: 'c',
+ })
+ expect(screen.getByText('pane:c')).toBeTruthy()
+ expect(screen.queryByText('pane:a')).toBeNull()
+ // Alone under the strip, the pane is the page again: it pins, inset by
+ // the strip's height.
+ expect(screen.getByText('pane:c').dataset.fit).toBe('true')
+ expect(screen.getByText('pane:c').dataset.inset).not.toBe('0')
+ })
+
+ it('offers the strip + when a new-tab handler is given', async () => {
+ setWidth(1280)
+ const onNewTab = vi.fn()
+ renderGroup({ tabs: [AB_ROW, { leaf: 'c' }], panes: [
+ { id: 'a', label: '' },
+ { id: 'b', label: '' },
+ { id: 'c', label: '' },
+ ], onNewTab })
+ await userEvent.click(screen.getByRole('button', { name: 'New tab in this group' }))
+ expect(onNewTab).toHaveBeenCalled()
+ })
+
+ it('renders a flat tab strip and one pane on show on a phone, whatever the trees say', () => {
+ setWidth(390)
+ renderGroup()
+ expect(screen.getByRole('tablist')).toBeTruthy()
+ expect(screen.getByText('pane:a')).toBeTruthy()
+ expect(screen.queryByText('pane:b')).toBeNull()
+ expect(screen.queryByRole('separator')).toBeNull()
+ // The pane is told about the strip above it, for the viewport pinning.
+ expect(screen.getByText('pane:a').dataset.inset).not.toBe('0')
+ })
+
+ it('reports a tab pick and marks the shown tab selected', async () => {
+ setWidth(390)
+ const { props } = renderGroup()
+ expect(screen.getByRole('tab', { name: 'api server' }).getAttribute('aria-selected')).toBe(
+ 'true',
+ )
+ await userEvent.click(screen.getByRole('tab', { name: 'logs' }))
+ expect(props.onActivate).toHaveBeenCalledWith('b')
+ })
+
+ it('falls back to the first pane when the active id has gone', () => {
+ setWidth(390)
+ renderGroup({ active: 'gone' })
+ expect(screen.getByText('pane:a')).toBeTruthy()
+ expect(screen.getByRole('tab', { name: 'api server' }).getAttribute('aria-selected')).toBe(
+ 'true',
+ )
+ })
+
+ it('names an unnamed pane by its position', () => {
+ setWidth(390)
+ renderGroup({
+ panes: [
+ { id: 'a', label: '' },
+ { id: 'b', label: '' },
+ ],
+ })
+ expect(screen.getByRole('tab', { name: 'Terminal 1' })).toBeTruthy()
+ expect(screen.getByRole('tab', { name: 'Terminal 2' })).toBeTruthy()
+ })
+})
diff --git a/web/src/components/session-group.tsx b/web/src/components/session-group.tsx
new file mode 100644
index 0000000..3a77d87
--- /dev/null
+++ b/web/src/components/session-group.tsx
@@ -0,0 +1,335 @@
+import { useCallback, useEffect, useRef, type ReactNode } from 'react'
+import { PlusIcon } from 'lucide-react'
+
+import { useIsMobile } from '@/hooks/use-mobile'
+import { leafIds, tabOf, type PaneTree, type TreePath } from '@/sessions/pane-tree'
+import { cn } from '@/lib/utils'
+
+/**
+ * The tab strips' heights in px (h-11 and h-9 below). Handed to panes as
+ * their viewportInset, so the strip and the pinning formula cannot disagree
+ * about how tall the strip is.
+ */
+const MOBILE_STRIP_PX = 44
+const DESKTOP_STRIP_PX = 36
+
+/** One pane of a group: the session, and what a tab calls it. */
+export interface GroupPane {
+ id: string
+ label: string
+}
+
+export interface SessionGroupProps {
+ /**
+ * The desktop arrangement: one split tree per tab (sessions/pane-tree.ts),
+ * owned and persisted by the route, which is where splits are made and
+ * members reconciled. Tabs and splits compose — a tab holds a whole split
+ * arrangement, and "new tab" adds a tab without disturbing it. Out of step
+ * is survivable: the route reconciles against the members on every change.
+ */
+ tabs: PaneTree[]
+ /** Every pane in order — anchor first — for labels and the phone's flat tabs. */
+ panes: GroupPane[]
+ /** A divider settled: the split at `path` inside tab `tab` wants `ratio`. */
+ onRatio: (tab: number, path: TreePath, ratio: number) => void
+ /** The pane in front: the phone's tab, or the desktop tab that holds it. */
+ active: string
+ onActivate: (id: string) => void
+ /** The strip's `+`: a new tab, in the directory of the active pane. */
+ onNewTab?: () => void
+ /**
+ * Draw one pane's terminal. `viewportInset` is the in-flow chrome above
+ * the pane — a tab strip's height, zero elsewhere — for the terminal's
+ * visual-viewport pinning; key the terminal on it. `fit` says whether this
+ * pane may pin itself to the visual viewport at all: true when it is the
+ * page, false for every pane of a multi-pane split — the pinning slots are
+ * single-occupancy (lib/viewport.ts), and N sibling panes each grabbing
+ * them would leave all but the last frozen at mount-time height.
+ */
+ renderPane: (id: string, viewportInset: number, fit: boolean) => ReactNode
+}
+
+/**
+ * One session group, rendered for the screen it is on: tabs of split trees
+ * on a desktop, a flat tab strip on a phone — a phone has room for exactly
+ * one pane, so its tabs are the members and the trees are ignored.
+ *
+ * A group of one deliberately does not read as a group at all — no strip, no
+ * divider, exactly the full-bleed terminal every session has always been.
+ * That is the backward-compatibility floor the issue draws: a plain session
+ * is the degenerate case, not a special one.
+ */
+export function SessionGroup({
+ tabs,
+ panes,
+ onRatio,
+ active,
+ onActivate,
+ onNewTab,
+ renderPane,
+}: SessionGroupProps) {
+ const isMobile = useIsMobile()
+ const labels = new Map(panes.map((p, i) => [p.id, p.label === '' ? `Terminal ${i + 1}` : p.label]))
+
+ if (panes.length <= 1) {
+ const only = panes[0]?.id ?? active
+ return
+ )
+ }
+
+ // Desktop: the tab that holds the active pane, first tab when none does.
+ const at = Math.max(0, tabOf(tabs, active))
+ const tree = tabs[at]
+ if (tree === undefined) {
+ // The tree has not caught up with the members yet (first list still in
+ // flight). One pane, plainly, until it does.
+ return
+ )
+}
+
+/**
+ * The tab strip, in flow above the panes rather than floating over them — a
+ * strip overlaid on a pane would sit on the first row of every full-screen
+ * program. Compact on a desktop, finger-sized on a phone; quiet by the nav
+ * rules: colour and a soft tint mark the selected tab, never weight.
+ */
+function Strip({
+ height,
+ tabs,
+ onPick,
+ onNewTab,
+}: {
+ height: 'mobile' | 'desktop'
+ tabs: Array<{ key: string; label: string; selected: boolean }>
+ onPick: (key: string) => void
+ onNewTab?: () => void
+}) {
+ // The selected tab keeps itself in view: the cycle chord can land on a tab
+ // the strip has scrolled past, and a selection nobody can see reads as the
+ // chord doing nothing. By hand rather than scrollIntoView, which walks
+ // every scrollable ancestor and would be free to nudge the page.
+ const selectedEl = useRef(null)
+ const selectedKey = tabs.find((t) => t.selected)?.key
+ useEffect(() => {
+ const el = selectedEl.current
+ const strip = el?.parentElement
+ if (el == null || strip == null) return
+ const left = el.offsetLeft
+ const right = left + el.offsetWidth
+ if (left < strip.scrollLeft) strip.scrollLeft = left - 8
+ else if (right > strip.scrollLeft + strip.clientWidth) {
+ strip.scrollLeft = right - strip.clientWidth + 8
+ }
+ }, [selectedKey])
+
+ return (
+
+}
diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx
index 2ee872a..c1f8438 100644
--- a/web/src/components/session-table.tsx
+++ b/web/src/components/session-table.tsx
@@ -164,6 +164,7 @@ const TAG_CAP = 3
*/
function SessionRow({
s,
+ paneCount,
shown,
selected,
onToggleSelect,
@@ -171,6 +172,8 @@ function SessionRow({
peek,
}: {
s: FleetSession
+ /** Panes folded into this row, when its group has more than one. */
+ paneCount?: number
shown: ColumnKey[]
selected: ReadonlySet
onToggleSelect: (key: string) => void
@@ -200,6 +203,15 @@ function SessionRow({
{name}
+ {paneCount !== undefined && paneCount > 1 && (
+ // The whole group folded to this one row (sessions/groups.ts), and
+ // the fold has to say so — a group that reads as one plain session
+ // is N-1 terminals nobody can account for. "Terminals", not panes
+ // or tabs: those are per-device renderings of the same group.
+
+ {paneCount} terminals
+
+ )}
{s.cmd.join(' ')}
@@ -387,6 +399,7 @@ function PinnedRule() {
*/
export function SessionTable({
groups,
+ panes,
columns,
selected,
onToggleSelect,
@@ -398,6 +411,12 @@ export function SessionTable({
peek,
}: {
groups: Group[]
+ /**
+ * keyOf(row) -> how many panes that row's session group folded into it,
+ * from foldGroups. Rows it does not name are ordinary sessions; the badge
+ * only renders past one.
+ */
+ panes?: ReadonlyMap
columns: ColumnKey[]
selected: ReadonlySet
onToggleSelect(key: string): void
@@ -533,6 +552,7 @@ export function SessionTable({
{at === boundary && at > 0 && }
): KeyboardEvent {
+ return {
+ key: '',
+ code: '',
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ ...over,
+ } as KeyboardEvent
+}
+
+describe('matchHelpChord', () => {
+ it('reads ⌘/ on a Mac and Ctrl+Shift+/ elsewhere', () => {
+ expect(matchHelpChord(key({ metaKey: true, key: '/', code: 'Slash' }), true)).toBe(true)
+ expect(
+ matchHelpChord(key({ ctrlKey: true, shiftKey: true, key: '?', code: 'Slash' }), false),
+ ).toBe(true)
+ })
+
+ it('never claims plain Ctrl+/ — that is readline’s undo', () => {
+ expect(matchHelpChord(key({ ctrlKey: true, key: '/', code: 'Slash' }), false)).toBe(false)
+ expect(matchHelpChord(key({ ctrlKey: true, key: '/', code: 'Slash' }), true)).toBe(false)
+ })
+
+ it('refuses extra modifiers and other keys', () => {
+ expect(
+ matchHelpChord(key({ metaKey: true, shiftKey: true, key: '/', code: 'Slash' }), true),
+ ).toBe(false)
+ expect(matchHelpChord(key({ metaKey: true, key: 'k', code: 'KeyK' }), true)).toBe(false)
+ })
+
+ it('prints the platform spelling', () => {
+ expect(helpChordLabel(true)).toBe('⌘/')
+ expect(helpChordLabel(false)).toBe('Ctrl+Shift+/')
+ })
+})
+
+describe('ShortcutsHelp', () => {
+ it('opens from the chip and lists every section', async () => {
+ render()
+ expect(screen.queryByRole('dialog')).toBeNull()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Keyboard shortcuts' }))
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ for (const label of ['Sessions', 'This session', 'Terminal']) {
+ expect(screen.getByRole('heading', { name: label })).toBeTruthy()
+ }
+ expect(screen.getByText('Split right')).toBeTruthy()
+ expect(screen.getByText('Scratch terminal')).toBeTruthy()
+ })
+
+ it('hides the chip for a finger, keeping the chord for a hardware keyboard', () => {
+ render()
+ expect(screen.queryByRole('button', { name: 'Keyboard shortcuts' })).toBeNull()
+ act(() => {
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key: '?', code: 'Slash', ctrlKey: true, shiftKey: true }),
+ )
+ })
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ })
+
+ it('toggles on its own chord', () => {
+ render()
+ // jsdom's navigator is not a Mac, so the chord is Ctrl+Shift+/.
+ const chord = () =>
+ act(() => {
+ window.dispatchEvent(
+ new KeyboardEvent('keydown', { key: '?', code: 'Slash', ctrlKey: true, shiftKey: true }),
+ )
+ })
+ chord()
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ chord()
+ expect(screen.queryByRole('dialog')).toBeNull()
+ })
+})
diff --git a/web/src/components/shortcuts-help.tsx b/web/src/components/shortcuts-help.tsx
new file mode 100644
index 0000000..9a88f8e
--- /dev/null
+++ b/web/src/components/shortcuts-help.tsx
@@ -0,0 +1,187 @@
+import { useEffect, useMemo, useState, type CSSProperties } from 'react'
+import { Dialog } from 'radix-ui'
+import { KeyboardIcon, XIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ newTabChordLabel,
+ splitChordLabel,
+ tabCycleChordLabel,
+} from '@/lib/split-keys'
+import { cn } from '@/lib/utils'
+import { isApplePlatform, openChordLabel } from '@/switcher/keys'
+
+/**
+ * Whether a keystroke asked for this card. ⌘/ on a Mac — the palette-help
+ * spelling every app with a palette uses, and a Cmd chord never reaches the
+ * shell. Elsewhere it is Ctrl+Shift+/, in the Ctrl+Shift namespace the
+ * terminal reserves for its own chrome (#64): plain Ctrl+/ is readline's
+ * undo and may not be taken. Matched on `code` first, as every chord here
+ * is — Shift turns / into ? on most layouts.
+ */
+export function matchHelpChord(e: KeyboardEvent, apple: boolean): boolean {
+ const slash = e.code === 'Slash' || e.key === '/' || e.key === '?'
+ if (!slash) return false
+ if (apple) return e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey
+ return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey
+}
+
+/** How the help chord is printed. */
+export function helpChordLabel(apple: boolean): string {
+ return apple ? '⌘/' : 'Ctrl+Shift+/'
+}
+
+interface Row {
+ what: string
+ keys: string
+}
+
+interface Section {
+ label: string
+ rows: Row[]
+}
+
+/**
+ * Everything the keyboard can do here, spelled for this keyboard. A static
+ * card rather than something detected: it is documentation, and a row for a
+ * verb this daemon cannot serve still teaches what flue is.
+ */
+function sections(apple: boolean): Section[] {
+ return [
+ {
+ label: 'Sessions',
+ rows: [
+ { what: 'Switch session', keys: openChordLabel(apple) },
+ { what: 'Next / previous session', keys: apple ? '⌃⇧] ⌃⇧[' : 'Ctrl+Shift+] [' },
+ { what: 'Pinned session 1–9', keys: apple ? '⌃⇧1–9' : 'Ctrl+Shift+1–9' },
+ ],
+ },
+ {
+ label: 'This session',
+ rows: [
+ { what: 'Split right', keys: splitChordLabel(apple, 'row') },
+ { what: 'Split down', keys: splitChordLabel(apple, 'column') },
+ { what: 'New tab', keys: newTabChordLabel(apple) },
+ { what: 'Next / previous tab', keys: tabCycleChordLabel(apple) },
+ { what: 'Scratch terminal', keys: apple ? '⌃ ⌃ (double-tap)' : 'Ctrl Ctrl (double-tap)' },
+ ],
+ },
+ {
+ label: 'Terminal',
+ rows: [
+ // The literal matches TERMINAL_SHORTCUT_HINT in terminal.tsx; spelled
+ // out here because importing it would close a cycle.
+ { what: 'Focus mode — every key to the shell', keys: 'Ctrl+Shift+Enter' },
+ { what: 'Copy the selection', keys: apple ? '⌘C' : 'Ctrl+C (with a selection)' },
+ { what: 'This card', keys: helpChordLabel(apple) },
+ ],
+ },
+ ]
+}
+
+/**
+ * The keyboard shortcuts card and the chip that opens it.
+ *
+ * A chip in the terminal's control strip, because the chords it teaches are
+ * the terminal's — and because the pills that used to teach them are gone in
+ * a hundred milliseconds on a local daemon. One instance per surface: the
+ * route mounts the strip on one pane, so the chord listener here is single
+ * too.
+ */
+export function ShortcutsHelp({
+ chipStyle,
+ chip = true,
+}: {
+ chipStyle: CSSProperties
+ /**
+ * Whether to draw the chip at all. False on a coarse pointer: a card of
+ * keyboard chords is furniture on a phone. The chord listener stays either
+ * way — an iPad grows a hardware keyboard without changing its pointer,
+ * and ⌘/ should answer it.
+ */
+ chip?: boolean
+}) {
+ const apple = useMemo(() => isApplePlatform(), [])
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (!matchHelpChord(e, apple)) return
+ // Capture, and stopped, for the reason every chord here is: left to
+ // bubble, xterm turns the keystroke into bytes first.
+ e.preventDefault()
+ e.stopPropagation()
+ setOpen((v) => !v)
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => window.removeEventListener('keydown', onKey, true)
+ }, [apple])
+
+ return (
+
+ {chip && (
+
+
+
+ )}
+
+
+
+
+
+
+ Keyboard shortcuts
+
+
+
+
+
+
+ {sections(apple).map((section) => (
+
+
+ {section.label}
+
+
+ {section.rows.map((row) => (
+
+
+ {row.what}
+
+
+ {row.keys}
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx
index 2dfa8b5..328d63e 100644
--- a/web/src/components/terminal.test.tsx
+++ b/web/src/components/terminal.test.tsx
@@ -1790,19 +1790,30 @@ function session(over: Partial = {}): SessionInfo {
}
}
-describe('the exit overlay', () => {
- it('appears when the shell exits, naming the code', () => {
- const { sock } = mountTerminal((em) => )
+describe('an exited shell', () => {
+ it('hands the exit straight to onClosed — no overlay, no question', () => {
+ const onClosed = vi.fn()
+ const { sock } = mountTerminal((em) => (
+
+ ))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
- act(() => sock.emitControl({ type: 'exit', ref: 1, code: 130 }))
+ expect(onClosed).not.toHaveBeenCalled()
- const card = screen.getByRole('alertdialog')
- expect(card.getAttribute('aria-label')).toBe('shell exited (130)')
- expect(card.textContent).toContain('(130)')
+ act(() => sock.emitControl({ type: 'exit', ref: 1, code: 130 }))
+ // What closing means — fold a pane, dismiss the scratch modal, leave the
+ // route, or keep a corpse on screen to read — is the caller's decision,
+ // which is why the exit is reported rather than acted on.
+ expect(onClosed).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('alertdialog')).toBeNull()
+ // Nothing is sent for the dead session: the exit already retired its ref
+ // on both ends, and the daemon reaps it on its own schedule.
+ expect(sock.ofType('close')).toHaveLength(0)
})
- it('dims the terminal but leaves it in the tree, scrollback intact', () => {
+ it('dims the scrollback for a view that stays', () => {
+ // A caller that keeps the view — reading a session that was already over
+ // — gets the dimmed pane and the pill, with the scrollback intact.
const { sock } = mountTerminal((em) => )
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
@@ -1810,52 +1821,6 @@ describe('the exit overlay', () => {
act(() => sock.emitControl({ type: 'exit', ref: 1, code: 0 }))
expect(inset().className).toContain('opacity-60')
- // The wrapper must not eat events meant for the scrollback under it.
- expect(screen.getByRole('alertdialog').parentElement!.className).toContain('pointer-events-none')
- })
-
- it('Restart spawns in the dead session’s directory, closes it, and hands over', () => {
- const onRestarted = vi.fn()
- const { sock } = mountTerminal((em) => (
-
- ))
-
- act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
- act(() => sock.emitControl({ type: 'sessions', sessions: [session()] }))
- act(() => sock.emitControl({ type: 'exit', ref: 1, code: 0 }))
-
- fireEvent.click(screen.getByRole('button', { name: 'Restart' }))
- const spawns = sock.ofType('spawn')
- expect(spawns).toHaveLength(1)
- expect(spawns[0]).toMatchObject({ cwd: '/home/karn/code', cols: 80, rows: 24 })
-
- // A second click while the first is unanswered must not start a second
- // shell.
- fireEvent.click(screen.getByRole('button', { name: 'Restart' }))
- expect(sock.ofType('spawn')).toHaveLength(1)
-
- const reqId = spawns[0]!.reqId as number
- act(() => sock.emitControl(attached({ ref: 9, id: 's2', reqId })))
-
- // The new ref goes straight back — the next route attaches for itself.
- // Nothing is sent for the dead session: the exit already retired its ref
- // on both ends, and the daemon reaps it after ExitedRetention.
- expect(sock.ofType('detach')).toContainEqual({ type: 'detach', ref: 9 })
- expect(onRestarted).toHaveBeenCalledWith('s2')
- })
-
- it('Close just leaves — the daemon reaps an exited session on its own', () => {
- const onClosed = vi.fn()
- const { sock } = mountTerminal((em) => (
-
- ))
-
- act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
- act(() => sock.emitControl({ type: 'exit', ref: 1, code: 1 }))
-
- fireEvent.click(screen.getByRole('button', { name: 'Close' }))
- expect(sock.ofType('close')).toHaveLength(0)
- expect(onClosed).toHaveBeenCalled()
})
})
diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx
index f60be83..128d1fa 100644
--- a/web/src/components/terminal.tsx
+++ b/web/src/components/terminal.tsx
@@ -1,17 +1,26 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
-import { ArrowLeftRightIcon, LayoutGridIcon, PlusIcon } from 'lucide-react'
+import { DropdownMenu } from 'radix-ui'
+import {
+ ArrowLeftRightIcon,
+ Columns2Icon,
+ LayoutGridIcon,
+ PanelTopIcon,
+ PlusIcon,
+ Rows2Icon,
+ SquareTerminalIcon,
+} from 'lucide-react'
import { useFlueClient } from '@/client/provider'
-import { ExitOverlay } from '@/components/exit-overlay'
import { KeyBar } from '@/components/key-bar'
import { PasteBox } from '@/components/paste-box'
import { SelectionMenu, type MenuEnd } from '@/components/selection-menu'
+import { ShortcutsHelp } from '@/components/shortcuts-help'
import { ThemeMenu } from '@/components/theme-menu'
import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette'
import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes'
import type { Emulator } from '@/emulator/types'
import { createXtermEmulator, type XtermOptions } from '@/emulator/xterm'
-import { loadThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref'
+import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref'
import {
cellAt,
cellBox,
@@ -23,9 +32,11 @@ import {
} from '@/lib/geometry'
import { startGlide } from '@/lib/glide'
import { createKeyboardModes, type KeyboardMode } from '@/lib/keyboard'
+import { newTabChordLabel, splitChordLabel, type GroupLayout } from '@/lib/split-keys'
import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys'
import { cn } from '@/lib/utils'
import { trackVisualViewport, zoomedIn } from '@/lib/viewport'
+import { useScratch } from '@/scratch/context'
import { isApplePlatform, openChordLabel } from '@/switcher/keys'
import { useSwitcher } from '@/switcher/provider'
@@ -48,11 +59,13 @@ export interface TerminalProps {
*/
createEmulator?: (opts: XtermOptions) => Emulator
/**
- * Called with the new session's id once a Restart's spawn has attached.
- * The route supplies navigation; the component never touches the router.
+ * Called the moment the shell exits, and by nothing else. What "closed"
+ * means belongs to the view above: a split pane folds away, the scratch
+ * modal dismisses, a lone session leaves for the dashboard — and a session
+ * that was *already* exited when it was opened is the caller's to keep on
+ * screen, which is why the daemon's whole exited-retention window exists.
+ * The component never touches the router.
*/
- onRestarted?: (sessionId: string) => void
- /** Called after Close has closed the dead session; navigate away here. */
onClosed?: () => void
/**
* Called by the `+` in the control strip, with this session's directory when
@@ -64,6 +77,46 @@ export interface TerminalProps {
* would be a terminal that could not be mounted without one.
*/
onNewSession?: (cwd: string | null) => void
+ /**
+ * Called by the split and new-tab rows in the `+` menu, with this
+ * session's directory and how the group should render from now on. The
+ * rows only exist when this is provided, which is how the route
+ * feature-detects: no handler on a daemon without the `multiplex` cap, no
+ * rows. What a split *is* — a member session, a pane, a tab — lives above
+ * this component with the group layout; the terminal only offers the
+ * verbs.
+ */
+ onSplit?: (cwd: string | null, layout: GroupLayout) => void
+ /**
+ * How much floating chrome to draw. `minimal` is for the scratch modal,
+ * which has its own frame and its own dismiss: the status pill stays — a
+ * scratch that cannot say "Reconnecting…" is a black box — and every
+ * navigation chip goes, because navigating away from inside a modal is not
+ * a place anyone means to go.
+ */
+ chrome?: 'full' | 'minimal'
+ /**
+ * Whether the pane pins itself to the visual viewport (lib/viewport.ts).
+ * True everywhere the terminal is the page — which is what the pinning
+ * formula assumes. The desktop scratch modal turns it off: a centered
+ * dialog is not at the top of the page, and a pane forced to viewport
+ * height would burst it.
+ */
+ fitViewport?: boolean
+ /**
+ * Height in px of in-flow chrome above the pane — the mobile tab strip.
+ * Read once at mount (it feeds the viewport tracker); the group view keys
+ * this component on it, so a strip appearing remounts rather than drifts.
+ */
+ viewportInset?: number
+ /**
+ * Whether this view writes the browser tab's title. True everywhere the
+ * terminal is the tab's subject; the route turns it off for every split
+ * pane that is not the URL session, and the scratch modal always — with
+ * several mounted at once, whoever registered last would otherwise name
+ * the tab.
+ */
+ ownsTitle?: boolean
}
/** Named so the test and the markup cannot drift apart. */
@@ -151,12 +204,17 @@ const PRESS_SLOP = 10
export function Terminal({
sessionId,
createEmulator = createXtermEmulator,
- onRestarted,
onClosed,
onNewSession,
+ onSplit,
+ chrome = 'full',
+ fitViewport = true,
+ viewportInset = 0,
+ ownsTitle = true,
}: TerminalProps) {
const client = useFlueClient()
const switcher = useSwitcher()
+ const scratch = useScratch()
// Which modifier this keyboard actually has, for the chip's tooltip. Once per
// mount: nobody swaps a Mac for a ThinkPad mid-session.
const chordLabel = useMemo(() => openChordLabel(isApplePlatform()), [])
@@ -187,7 +245,6 @@ export function Terminal({
const [ctrlArmed, setCtrlArmed] = useState(false)
const ctrlArmedRef = useRef(ctrlArmed)
ctrlArmedRef.current = ctrlArmed
- const [exitCode, setExitCode] = useState(null)
// The touch menu: which end of the terminal it is at, and whether the
// press it came from found anything to copy. Null for not showing.
//
@@ -222,7 +279,6 @@ export function Terminal({
// ends, and an exited session reaps itself after ExitedRetention — Close
// just leaves.
const actionsRef = useRef<{
- restart: (dir: string | null) => void
applyTheme: (id: string) => void
sendKey: (key: BarKey) => void
copy: () => void
@@ -230,10 +286,14 @@ export function Terminal({
pasteText: (text: string) => void
dismiss: () => void
} | null>(null)
- // The latest onRestarted, readable from inside the effect without putting
- // a prop identity in its dependency array.
- const restartedRef = useRef(onRestarted)
- restartedRef.current = onRestarted
+ // The latest onClosed, readable from inside the effect without putting a
+ // prop identity in its dependency array.
+ const closedRef = useRef(onClosed)
+ closedRef.current = onClosed
+ // Same treatment for tab-title ownership, which flips without a remount
+ // when the URL moves between two panes of one group.
+ const ownsTitleRef = useRef(ownsTitle)
+ ownsTitleRef.current = ownsTitle
useEffect(() => {
const pane = paneRef.current
@@ -337,9 +397,6 @@ export function Terminal({
// reconnect must not walk the pill back to "Reconnecting…" and imply that
// waiting will help.
let over = false
- // The reqId of a Restart's spawn, unanswered. Doubles as the click guard:
- // one restart in flight at a time, per mount.
- let restartReq: number | null = null
let frame = 0
// The pty-resize debounce. A browser sidebar sliding open resizes the
// pane on every animation frame, and each new pty size is a SIGWINCH the
@@ -365,6 +422,7 @@ export function Terminal({
let tabOsc = ''
let tabCwd = ''
const retitle = () => {
+ if (!ownsTitleRef.current) return
const at = tabOsc || tabCwd
const text = tabName && at ? `${tabName} — ${at}` : tabName || at
if (text) document.title = text
@@ -738,13 +796,17 @@ export function Terminal({
// The pane hugs the visual viewport: a phone keyboard shrinks it and the
// ResizeObserver below refits the terminal above the keyboard. While
- // pinch-zoomed it instead releases the surface so one finger pans.
- const untrackViewport = trackVisualViewport({
- pane,
- surface,
- gestureArea: inner,
- viewport: window.visualViewport,
- })
+ // pinch-zoomed it instead releases the surface so one finger pans. The
+ // desktop scratch modal opts out — see fitViewport on the props.
+ const untrackViewport = fitViewport
+ ? trackVisualViewport({
+ pane,
+ surface,
+ gestureArea: inner,
+ viewport: window.visualViewport,
+ topInset: viewportInset,
+ })
+ : () => {}
// Every registration returns an unsubscribe, and all of them are released
// on cleanup: the client outlives this view by design.
@@ -783,16 +845,6 @@ export function Terminal({
offs.push(
client.onAttached((a) => {
- if (restartReq !== null && a.reqId === restartReq) {
- // The Restart's own spawn. Hand the new ref straight back — the
- // route this navigates to attaches for itself — and go. The dead
- // session needs nothing: the exit already retired its ref on both
- // ends, and the daemon reaps it after ExitedRetention.
- restartReq = null
- client.detach(a.ref)
- restartedRef.current?.(a.id)
- return
- }
// One client serves the whole tab, so every listener sees every reply.
if (a.id !== sessionId) return
ref = a.ref
@@ -858,8 +910,14 @@ export function Terminal({
if (r !== ref) return
over = true
emulator.write(EXIT_NOTICE(code))
- setExitCode(code)
setPhase('exited')
+ // The view above decides what an exit closes — a pane, a modal, the
+ // whole route — or keeps: a session opened after it had already
+ // exited is being read, and reading is the point of the daemon's
+ // exited-retention window. There is no overlay in between; a shell
+ // that ends is over, and asking Restart-or-Close on every `exit 0`
+ // answered a question nobody was asking.
+ closedRef.current?.()
}),
)
@@ -905,6 +963,18 @@ export function Terminal({
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Enter' || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return
+ // With splits and the scratch modal, several Terminals listen on this
+ // window at once, and stopPropagation cannot silence siblings on the
+ // same target and phase — unguarded, every pane would race its own
+ // requestFullscreen. The pane holding the keyboard answers the chord;
+ // when no pane holds it, only a lone terminal may (a chord over an
+ // ambiguous split does nothing rather than something arbitrary).
+ const active = document.activeElement
+ if (!pane.contains(active)) {
+ const owner = active instanceof Element ? active.closest('[data-flue-inset]') : null
+ if (owner !== null) return
+ if (document.querySelectorAll('[data-flue-inset]').length > 1) return
+ }
// Capture, and stopped here. Left to bubble, xterm's own handler on the
// helper textarea would already have turned it into a carriage return
// and sent it to the shell before this ran.
@@ -937,14 +1007,6 @@ export function Terminal({
media?.addEventListener?.('change', onScheme)
actionsRef.current = {
- // Click-driven only, for the same StrictMode reason session creation
- // lives on the sessions screen: a spawn fired from a mount effect runs
- // twice and can only ever detach one of its shells.
- restart: (dir) => {
- if (restartReq !== null) return
- const reqId = client.spawn({ cwd: dir ?? undefined, cols: dims.cols, rows: dims.rows })
- if (reqId !== null) restartReq = reqId
- },
applyTheme: (id) => {
const next = resolveTheme(id, prefersDark())
emulator.setTheme(next)
@@ -1008,9 +1070,8 @@ export function Terminal({
// Another tab choosing a theme lands here: the preference is global, and
// a change should sweep every open terminal, not just the one clicked.
- // The storage event only ever fires in *other* tabs; the clicking tab
- // goes through handleTheme. A null key is storage.clear() — back to
- // system either way.
+ // The storage event only ever fires in *other* tabs. A null key is
+ // storage.clear() — back to system either way.
const onStorage = (e: StorageEvent) => {
if (e.key !== null && e.key !== THEME_PREF_KEY) return
const id = e.newValue ?? THEME_SYSTEM
@@ -1018,6 +1079,15 @@ export function Terminal({
actionsRef.current?.applyTheme(id)
}
window.addEventListener('storage', onStorage)
+ // And a choice made in *this* document lands here — the half the storage
+ // event cannot deliver. With splits and the scratch modal, several
+ // terminals share one document, and the menu that took the click sits on
+ // exactly one of them; without this, that pane repainted and its
+ // siblings kept the old clothes until remount.
+ const offThemePref = onThemePref((id) => {
+ setThemeId(id)
+ actionsRef.current?.applyTheme(id)
+ })
client.attach(sessionId, 0)
// For the cwd: `attached` does not carry it, the session list does.
@@ -1036,6 +1106,7 @@ export function Terminal({
glide?.()
untrackViewport()
window.removeEventListener('storage', onStorage)
+ offThemePref()
window.removeEventListener('keydown', onKey, true)
observer.disconnect()
media?.removeEventListener?.('change', onScheme)
@@ -1048,7 +1119,9 @@ export function Terminal({
// what React's double-invoked mount effect does on every mount.
if (ref !== null) client.detach(ref)
else client.forget(sessionId)
- document.title = priorTitle
+ // Only the title's owner restores it: a split pane unmounting must not
+ // blank the name the URL pane is still entitled to.
+ if (ownsTitleRef.current) document.title = priorTitle
if (canvas.style.backgroundColor === paintedCanvas) {
canvas.style.backgroundColor = priorCanvas
}
@@ -1057,14 +1130,16 @@ export function Terminal({
}
emulator.dispose()
}
- }, [client, sessionId, createEmulator])
+ // fitViewport and viewportInset rebuild the emulator on change by design:
+ // both describe the box the terminal lives in, and the group view keys
+ // this component on them anyway.
+ }, [client, sessionId, createEmulator, fitViewport, viewportInset])
- const handleRestart = () => actionsRef.current?.restart(cwd)
- const handleClose = () => onClosed?.()
const handleTheme = (id: string) => {
setThemeId(id)
+ // The save announces to every terminal in this document — this pane
+ // included — so the apply is not repeated here.
saveThemePref(id)
- actionsRef.current?.applyTheme(id)
}
// What the floating controls wear: the resolved theme's own surfaces,
@@ -1083,6 +1158,10 @@ export function Terminal({
-
+ {chrome === 'full' && }
+ {chrome === 'full' && (
+ <>
{/*
The way to another session without leaving this one.
@@ -1194,29 +1275,45 @@ export function Terminal({
Open the flue dashboard
{/*
- A button, and it used to be a link to `/?cwd=` — which is the
- dashboard, so a session started from here came up behind the whole
- list for as long as the daemon took to answer. It asks first now:
- the dialog above this component collects a name and tags while there
- is still a form to collect them on, and the page it opens starts the
- session on its own ground.
-
- The same box as the theme trigger — an icon in px-2.5 py-1.5 — so the
- cluster reads as one control strip, not two heights.
+ One `+` for everything that creates a terminal, so the strip stays
+ four chips however many verbs a daemon offers. On a daemon without
+ the `multiplex` cap (no onSplit, no scratch) it is the plain
+ new-session button it always was; otherwise it opens a menu of the
+ four verbs with their chords beside them — which is also where the
+ chords are taught, the way the switcher chip's tooltip teaches ⌘K.
*/}
-
+ {onSplit === undefined && !scratch.enabled ? (
+
+ ) : (
+ onNewSession?.(cwd)}
+ onSplit={onSplit === undefined ? undefined : (layout) => onSplit(cwd, layout)}
+ onScratch={scratch.enabled ? scratch.toggle : undefined}
+ />
+ )}
+ {/*
+ Every chord in one card, because the pills that used to teach them
+ are gone in a hundred milliseconds on a local daemon and the
+ tooltips teach one chord each. No chip for a finger — the card is
+ keyboard education — though the chord still answers a hardware
+ keyboard on a coarse-pointer device.
+ */}
+
+ >
+ )}
{phase !== 'live' && (
// Dark in both themes, like the pane it floats over usually is; the
// translucent ground and backdrop-blur keep it legible over whatever
@@ -1270,9 +1367,6 @@ export function Terminal({
)}
- {phase === 'exited' && exitCode !== null && (
-
- )}
)
}
@@ -1284,3 +1378,93 @@ const NOTICE: Record, string> = {
gone: 'This session is gone',
revoked: 'This device was revoked',
}
+
+/**
+ * The `+` menu: every verb that creates a terminal from here, in one chip.
+ * Dressed in the terminal's control colours like the theme menu beside it,
+ * and for the same reason spelled the same way — the content portals outside
+ * the pane that carries the --chip-* variables, so they are set again on the
+ * content explicitly.
+ */
+function PlusMenu({
+ chipStyle,
+ onNewSession,
+ onSplit,
+ onScratch,
+}: {
+ chipStyle: CSSProperties
+ onNewSession: () => void
+ onSplit?: (layout: GroupLayout) => void
+ onScratch?: () => void
+}) {
+ // Once per mount, like the switcher chip's tooltip: nobody swaps keyboards
+ // mid-session.
+ const apple = useMemo(() => isApplePlatform(), [])
+ const itemClass = cn(
+ 'flex cursor-default items-center gap-x-2.5 rounded-md px-2.5 py-1.5',
+ 'text-base/6 text-(--chip-fg) outline-none select-none sm:text-sm/6',
+ 'data-[highlighted]:bg-(--chip-wash)',
+ )
+ const chordClass = 'ml-6 font-mono text-xs text-(--chip-dim)'
+
+ return (
+
+
+
+
+
+
+
+
+ New session
+
+ {onSplit !== undefined && (
+ <>
+ onSplit('row')}>
+
+ Split right
+ {splitChordLabel(apple, 'row')}
+
+ onSplit('column')}>
+
+ Split down
+ {splitChordLabel(apple, 'column')}
+
+ onSplit('tabs')}>
+
+ New tab
+ {newTabChordLabel(apple)}
+
+ >
+ )}
+ {onScratch !== undefined && (
+
+
+ Scratch terminal
+ {apple ? '⌃ ⌃' : 'Ctrl Ctrl'}
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/fleet/fleet.ts b/web/src/fleet/fleet.ts
index 9c8a491..bdf39bb 100644
--- a/web/src/fleet/fleet.ts
+++ b/web/src/fleet/fleet.ts
@@ -986,6 +986,12 @@ export class FleetClient {
for (const slot of this.slots) {
if (slot.rows === null) continue
for (const row of slot.rows) {
+ // Ephemeral sessions are hidden here, at the one seam every fleet
+ // consumer reads through — the sessions list, the switcher, the
+ // recents. A scratch terminal is reachable from exactly one place,
+ // the session it was opened over, and that surface talks to the
+ // FlueClient directly rather than to these rows.
+ if (row.ephemeral) continue
out.push({ ...row, machineId: slot.id, machineName: slot.name })
}
}
diff --git a/web/src/lib/split-keys.test.ts b/web/src/lib/split-keys.test.ts
new file mode 100644
index 0000000..7920565
--- /dev/null
+++ b/web/src/lib/split-keys.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ matchNewTabChord,
+ matchSplitChord,
+ matchTabCycleChord,
+ newTabChordLabel,
+ splitChordLabel,
+ tabCycleChordLabel,
+} from './split-keys'
+
+function key(over: Partial): KeyboardEvent {
+ return {
+ key: '',
+ code: '',
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ metaKey: false,
+ ...over,
+ } as KeyboardEvent
+}
+
+describe('matchSplitChord', () => {
+ it('reads ⌘D as a side-by-side split and ⇧⌘D as a stacked one on a Mac', () => {
+ expect(matchSplitChord(key({ metaKey: true, key: 'd', code: 'KeyD' }), true)).toBe('row')
+ expect(
+ matchSplitChord(key({ metaKey: true, shiftKey: true, key: 'D', code: 'KeyD' }), true),
+ ).toBe('column')
+ })
+
+ it('reads Ctrl+Shift+D and Ctrl+Alt+Shift+D elsewhere', () => {
+ expect(
+ matchSplitChord(key({ ctrlKey: true, shiftKey: true, key: 'D', code: 'KeyD' }), false),
+ ).toBe('row')
+ expect(
+ matchSplitChord(
+ key({ ctrlKey: true, shiftKey: true, altKey: true, key: 'D', code: 'KeyD' }),
+ false,
+ ),
+ ).toBe('column')
+ })
+
+ it('never claims plain Ctrl+D — that is the shell EOF, not ours', () => {
+ expect(matchSplitChord(key({ ctrlKey: true, key: 'd', code: 'KeyD' }), false)).toBeNull()
+ expect(matchSplitChord(key({ ctrlKey: true, key: 'd', code: 'KeyD' }), true)).toBeNull()
+ })
+
+ it('refuses extra modifiers riding along', () => {
+ expect(
+ matchSplitChord(key({ metaKey: true, ctrlKey: true, key: 'd', code: 'KeyD' }), true),
+ ).toBeNull()
+ expect(
+ matchSplitChord(
+ key({ ctrlKey: true, shiftKey: true, metaKey: true, key: 'D', code: 'KeyD' }),
+ false,
+ ),
+ ).toBeNull()
+ })
+
+ it('matches on the physical key when the layout produced another character', () => {
+ // Shift over a non-US layout can spell D as something else entirely;
+ // `code` names the key itself, as the switcher's brackets do.
+ expect(matchSplitChord(key({ metaKey: true, key: 'Δ', code: 'KeyD' }), true)).toBe('row')
+ })
+
+ it('ignores other keys entirely', () => {
+ expect(matchSplitChord(key({ metaKey: true, key: 'e', code: 'KeyE' }), true)).toBeNull()
+ })
+})
+
+describe('matchNewTabChord', () => {
+ it('reads ⌥⌘T on a Mac — through the † the layout makes of Alt+T', () => {
+ expect(matchNewTabChord(key({ metaKey: true, altKey: true, key: '†', code: 'KeyT' }), true)).toBe(
+ true,
+ )
+ })
+
+ it('reads Ctrl+Alt+T elsewhere and never the browser-owned spellings', () => {
+ expect(
+ matchNewTabChord(key({ ctrlKey: true, altKey: true, key: 't', code: 'KeyT' }), false),
+ ).toBe(true)
+ // ⌘T and Ctrl+T open a browser tab; ⇧ variants reopen one. All refused.
+ expect(matchNewTabChord(key({ metaKey: true, key: 't', code: 'KeyT' }), true)).toBe(false)
+ expect(matchNewTabChord(key({ ctrlKey: true, key: 't', code: 'KeyT' }), false)).toBe(false)
+ expect(
+ matchNewTabChord(
+ key({ ctrlKey: true, altKey: true, shiftKey: true, key: 'T', code: 'KeyT' }),
+ false,
+ ),
+ ).toBe(false)
+ })
+})
+
+describe('matchTabCycleChord', () => {
+ it('steps right and left on the same modifier family as new-tab', () => {
+ const mods = { metaKey: true, altKey: true }
+ expect(matchTabCycleChord(key({ ...mods, key: 'ArrowRight', code: 'ArrowRight' }), true)).toBe(1)
+ expect(matchTabCycleChord(key({ ...mods, key: 'ArrowLeft', code: 'ArrowLeft' }), true)).toBe(-1)
+ const other = { ctrlKey: true, altKey: true }
+ expect(matchTabCycleChord(key({ ...other, key: 'ArrowRight', code: 'ArrowRight' }), false)).toBe(
+ 1,
+ )
+ })
+
+ it('leaves plain and shifted arrows to the shell and the browser', () => {
+ expect(matchTabCycleChord(key({ key: 'ArrowRight', code: 'ArrowRight' }), true)).toBeNull()
+ expect(
+ matchTabCycleChord(
+ key({ metaKey: true, altKey: true, shiftKey: true, key: 'ArrowLeft', code: 'ArrowLeft' }),
+ true,
+ ),
+ ).toBeNull()
+ })
+})
+
+describe('chord labels', () => {
+ it('print the platform spelling', () => {
+ expect(splitChordLabel(true, 'row')).toBe('⌘D')
+ expect(splitChordLabel(true, 'column')).toBe('⇧⌘D')
+ expect(splitChordLabel(false, 'row')).toBe('Ctrl+Shift+D')
+ expect(splitChordLabel(false, 'column')).toBe('Ctrl+Alt+Shift+D')
+ expect(newTabChordLabel(true)).toBe('⌥⌘T')
+ expect(newTabChordLabel(false)).toBe('Ctrl+Alt+T')
+ expect(tabCycleChordLabel(true)).toBe('⌥⌘← →')
+ expect(tabCycleChordLabel(false)).toBe('Ctrl+Alt+← →')
+ })
+})
diff --git a/web/src/lib/split-keys.ts b/web/src/lib/split-keys.ts
new file mode 100644
index 0000000..2c3af2f
--- /dev/null
+++ b/web/src/lib/split-keys.ts
@@ -0,0 +1,92 @@
+/*
+ * The split chords, next of kin to the switcher's (switcher/keys.ts) and
+ * bound by the same three constraints: browser-reserved combinations, a
+ * terminal underneath that owns most keys, and layouts that move characters
+ * around. The survivors:
+ *
+ * - Mac: ⌘D splits side by side, ⇧⌘D splits stacked — the pair Supacode
+ * and VS Code taught. A Cmd chord never reaches the shell, and Chrome
+ * lets a page preventDefault its bookmark shortcut.
+ * - Elsewhere: Ctrl+Shift+D and Ctrl+Alt+Shift+D. Plain Ctrl+D is EOF —
+ * the keystroke that closes shells — and may never be taken; Ctrl+Shift
+ * is the namespace a terminal emulator conventionally reserves for its
+ * own chrome (#64), and Alt is the only modifier left to say "the other
+ * axis".
+ */
+
+/**
+ * Which way one split lies: `row` is side by side (a split to the right),
+ * `column` is stacked (a split downward). Axes mix freely — every split in
+ * the tree (sessions/pane-tree.ts) carries its own.
+ */
+export type SplitDirection = 'row' | 'column'
+
+/**
+ * The three verbs that add a pane: split the asking pane along an axis, or
+ * open a new tab beside its tab. Tabs and splits compose — a tab holds a
+ * whole split tree — so this names the placement of one new pane, never a
+ * mode the group switches into.
+ */
+export type GroupLayout = SplitDirection | 'tabs'
+
+/** What a keystroke asked the split to do, or null for "not ours". */
+export function matchSplitChord(e: KeyboardEvent, apple: boolean): SplitDirection | null {
+ // `code` first for the reason the switcher's brackets use it — `key` is
+ // what the layout produced under Shift — with the character accepted
+ // behind it for layouts that report one.
+ const d = e.code === 'KeyD' || (e.key.length === 1 && e.key.toLowerCase() === 'd')
+ if (!d) return null
+
+ if (apple) {
+ if (!e.metaKey || e.ctrlKey || e.altKey) return null
+ return e.shiftKey ? 'column' : 'row'
+ }
+
+ if (!e.ctrlKey || !e.shiftKey || e.metaKey) return null
+ return e.altKey ? 'column' : 'row'
+}
+
+/** How a split chord is printed, for menu rows and tooltips. */
+export function splitChordLabel(apple: boolean, dir: SplitDirection): string {
+ if (apple) return dir === 'row' ? '⌘D' : '⇧⌘D'
+ return dir === 'row' ? 'Ctrl+Shift+D' : 'Ctrl+Alt+Shift+D'
+}
+
+/**
+ * Whether a keystroke asked for a new tab. ⌥⌘T on a Mac and Ctrl+Alt+T
+ * elsewhere, because the two natural spellings are both browser property:
+ * ⌘T/Ctrl+T opens a browser tab and ⇧⌘T/Ctrl+Shift+T reopens one, and
+ * neither can be taken back (#64). Matched on `code`: with Alt held a Mac
+ * layout produces † for the T key, which is exactly the trap the split
+ * chords dodge the same way.
+ */
+export function matchNewTabChord(e: KeyboardEvent, apple: boolean): boolean {
+ const t = e.code === 'KeyT' || (e.key.length === 1 && e.key.toLowerCase() === 't')
+ if (!t) return false
+ if (apple) return e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey
+ return e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey
+}
+
+/** How the new-tab chord is printed. */
+export function newTabChordLabel(apple: boolean): string {
+ return apple ? '⌥⌘T' : 'Ctrl+Alt+T'
+}
+
+/**
+ * Which neighbouring tab a keystroke asked for, or null. ⌥⌘←/→ on a Mac and
+ * Ctrl+Alt+←/→ elsewhere — the same family as the new-tab chord, because
+ * they are the same subject, and the browser owns every conventional
+ * spelling (Ctrl+Tab, ⌘⇧[ and ]). The switcher's ⌃⇧[ ] keeps meaning
+ * *sessions*; this one walks the tabs inside the group on screen.
+ */
+export function matchTabCycleChord(e: KeyboardEvent, apple: boolean): -1 | 1 | null {
+ const step = e.code === 'ArrowRight' ? 1 : e.code === 'ArrowLeft' ? -1 : null
+ if (step === null) return null
+ if (apple) return e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey ? step : null
+ return e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey ? step : null
+}
+
+/** How the tab-cycle pair is printed. */
+export function tabCycleChordLabel(apple: boolean): string {
+ return apple ? '⌥⌘← →' : 'Ctrl+Alt+← →'
+}
diff --git a/web/src/lib/theme-pref.test.ts b/web/src/lib/theme-pref.test.ts
index 5c1e99a..ffee317 100644
--- a/web/src/lib/theme-pref.test.ts
+++ b/web/src/lib/theme-pref.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { THEME_SYSTEM } from '@/emulator/themes'
-import { loadThemePref, saveThemePref, THEME_PREF_KEY } from './theme-pref'
+import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from './theme-pref'
afterEach(() => localStorage.clear())
@@ -23,4 +23,28 @@ describe('the theme preference', () => {
expect(localStorage.getItem(THEME_PREF_KEY)).toBeNull()
expect(loadThemePref()).toBe(THEME_SYSTEM)
})
+
+ it('announces every save to this document — how a split repaints all its panes', () => {
+ // The storage event only reaches other tabs, so with several terminals
+ // in one document (a split, the scratch modal) this listener is the only
+ // way a sibling pane hears the choice.
+ const heard: string[] = []
+ const offA = onThemePref((id) => heard.push(`a:${id}`))
+ const offB = onThemePref((id) => heard.push(`b:${id}`))
+ saveThemePref('dracula')
+ expect(heard).toEqual(['a:dracula', 'b:dracula'])
+
+ // System announces too — back-to-default must sweep the panes as well.
+ saveThemePref(THEME_SYSTEM)
+ expect(heard.slice(2)).toEqual([`a:${THEME_SYSTEM}`, `b:${THEME_SYSTEM}`])
+
+ // An unsubscribed listener stays quiet, and only its own registration
+ // retires.
+ offA()
+ saveThemePref('nord')
+ expect(heard.slice(4)).toEqual(['b:nord'])
+ offB()
+ saveThemePref('dracula')
+ expect(heard).toHaveLength(5)
+ })
})
diff --git a/web/src/lib/theme-pref.ts b/web/src/lib/theme-pref.ts
index da7f50c..7573831 100644
--- a/web/src/lib/theme-pref.ts
+++ b/web/src/lib/theme-pref.ts
@@ -6,8 +6,16 @@ import { THEME_SYSTEM } from '@/emulator/themes'
* Global on purpose — choosing a theme in one session applies to all of
* them, and a session spawned out of another wears the same clothes with no
* inheritance machinery at all. Per-session themes can come back later by
- * suffixing the key; the storage-event listeners in the terminal views are
- * what make a change land in every open tab the moment it is made.
+ * suffixing the key.
+ *
+ * A change travels two ways, because the browser splits the audience in two.
+ * The storage event reaches every *other* tab — and only them; the document
+ * that wrote never hears it. The listeners below are the other half: every
+ * terminal mounted in *this* document — the panes of a split, the scratch
+ * modal — hears a save the moment it is made. Before splits there was one
+ * terminal per document and the first half sufficed; a theme picked from a
+ * split's menu then repainted exactly one pane and left its siblings in the
+ * old clothes until remount.
*
* Client-side because the daemon holds what a terminal *is*; what it looks
* like in this browser is this browser's business. Every access is guarded:
@@ -34,4 +42,23 @@ export function saveThemePref(themeId: string): void {
} catch {
// Nothing to do: the theme still applies for this view's lifetime.
}
+ // Announced whether or not the write stuck: the panes on screen should
+ // follow the choice even where storage refuses to remember it.
+ for (const cb of [...listeners]) cb(themeId)
+}
+
+type ThemePrefListener = (themeId: string) => void
+
+const listeners = new Set()
+
+/**
+ * Hear theme choices made in this document — the half the storage event
+ * cannot deliver. Every mounted terminal subscribes; the returned
+ * unsubscribe retires exactly this registration.
+ */
+export function onThemePref(cb: ThemePrefListener): () => void {
+ listeners.add(cb)
+ return () => {
+ listeners.delete(cb)
+ }
}
diff --git a/web/src/lib/viewport.test.ts b/web/src/lib/viewport.test.ts
index fdd4c02..f1d2260 100644
--- a/web/src/lib/viewport.test.ts
+++ b/web/src/lib/viewport.test.ts
@@ -127,6 +127,58 @@ describe('trackVisualViewport', () => {
expect(pane.style.height).toBe('')
})
+ it('leaves the slots empty when every tracker is gone, in any dispose order', () => {
+ // The failure this pins down: A then B install, A disposes first (its
+ // slot check fails, so it restores nothing), then B disposes and puts
+ // back the handler it captured — A's, whose tracker is already dead. The
+ // resurrected closure would keep restyling A's detached pane on every
+ // keyboard move, and nothing live would own the slot.
+ const vv = fakeViewport({ height: 700 })
+ const paneB = document.createElement('div')
+ const surfaceB = document.createElement('div')
+ const disposeA = trackVisualViewport({ pane, surface, viewport: vv })
+ const disposeB = trackVisualViewport({ pane: paneB, surface: surfaceB, viewport: vv })
+
+ disposeA()
+ disposeB()
+ expect(vv.onresize).toBeNull()
+ expect(vv.onscroll).toBeNull()
+
+ vv.height = 400
+ vv.fire()
+ expect(pane.style.height).toBe('')
+ expect(paneB.style.height).toBe('')
+ })
+
+ it('hands the slot to the survivor when the top and a middle tracker dispose', () => {
+ // Three panes overlap, the middle then the top go away: the slot must
+ // fall to the one still alive — not to the middle one's dead handler,
+ // which is what a captured-previous chain restores.
+ const vv = fakeViewport({ height: 700 })
+ const paneB = document.createElement('div')
+ const paneC = document.createElement('div')
+ trackVisualViewport({ pane, surface, viewport: vv })
+ const disposeB = trackVisualViewport({
+ pane: paneB,
+ surface: document.createElement('div'),
+ viewport: vv,
+ })
+ const disposeC = trackVisualViewport({
+ pane: paneC,
+ surface: document.createElement('div'),
+ viewport: vv,
+ })
+
+ disposeB()
+ disposeC()
+
+ vv.height = 400
+ vv.fire()
+ expect(pane.style.height).toBe('400px')
+ expect(paneB.style.height).toBe('')
+ expect(paneC.style.height).toBe('')
+ })
+
it('does not unwire a newer tracker when an older one disposes', () => {
// A remount can install the replacement before tearing the old one down.
// The stale disposer must clear only its own pane, never the live
diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts
index e5cce40..6fd961e 100644
--- a/web/src/lib/viewport.ts
+++ b/web/src/lib/viewport.ts
@@ -57,8 +57,16 @@ export function trackVisualViewport(opts: {
surface: HTMLElement
gestureArea?: HTMLElement
viewport: ViewportLike | null
+ /**
+ * Chrome in normal flow above the pane — the group view's tab strip. The
+ * formula below assumes the pane's layout box starts at the top of the
+ * page; a pane that starts topInset pixels down must be that much shorter,
+ * or its bottom rows (and the prompt) land behind the keyboard by exactly
+ * the strip's height.
+ */
+ topInset?: number
}): () => void {
- const { pane, surface, gestureArea = surface, viewport } = opts
+ const { pane, surface, gestureArea = surface, viewport, topInset = 0 } = opts
if (!viewport) return () => {}
const apply = () => {
@@ -69,25 +77,83 @@ export function trackVisualViewport(opts: {
}
surface.style.touchAction = ''
gestureArea.style.touchAction = ''
- pane.style.height = `${viewport.height}px`
+ pane.style.height = `${Math.max(0, viewport.height - topInset)}px`
pane.style.translate = `0px ${viewport.offsetTop}px`
}
+ // The slots are single-occupancy by design (see ViewportLike on why they
+ // are properties), and trackers legitimately overlap now: the scratch
+ // modal's terminal mounts over the route's, and split panes stack several
+ // more. A dispose that nulled the slot would leave every survivor deaf to
+ // the keyboard — and a dispose that restored "whatever it found on
+ // install" resurrects a dead tracker's handler the moment they tear down
+ // out of install order. So the stack is explicit: one shared record per
+ // viewport, disposal removes exactly its own entry, and the slot always
+ // belongs to the newest entry still alive (or to whatever non-flue handler
+ // held it before the first tracker arrived).
+ const rec = stackFor(viewport)
+ const me = { apply }
+ rec.stack.push(me)
viewport.onresize = apply
viewport.onscroll = apply
apply()
return () => {
- // Each slot is surrendered only if it is still this tracker's. Nulling
- // unconditionally would be a disposer reaching past its own lifetime:
- // a remount can install the replacement before tearing down the old
- // tracker, and the old one would then strip the handlers the new one
- // just wired, leaving the pane stuck at whatever the keyboard last did.
- if (viewport.onresize === apply) viewport.onresize = null
- if (viewport.onscroll === apply) viewport.onscroll = null
surface.style.touchAction = ''
gestureArea.style.touchAction = ''
pane.style.height = ''
pane.style.translate = ''
+ const i = rec.stack.indexOf(me)
+ if (i === -1) return // disposed twice
+ const wasTop = i === rec.stack.length - 1
+ rec.stack.splice(i, 1)
+ if (rec.stack.length === 0) stacks.delete(viewport)
+ // A tracker below the top never held the slot, so it has nothing to hand
+ // over; and each slot is surrendered only if it is still this tracker's —
+ // a remount can install the replacement before tearing down the old
+ // tracker, and the old one must not strip the handlers the new one just
+ // wired, leaving the pane stuck at whatever the keyboard last did.
+ if (!wasTop) return
+ const next = rec.stack[rec.stack.length - 1]
+ let handedOver = false
+ if (viewport.onresize === apply) {
+ viewport.onresize = next ? next.apply : rec.prevResize
+ handedOver = next !== undefined
+ }
+ if (viewport.onscroll === apply) {
+ viewport.onscroll = next ? next.apply : rec.prevScroll
+ handedOver = handedOver || next !== undefined
+ }
+ // The successor takes the state over now, not on the next keyboard move:
+ // its pane may have been styled for a viewport several trackers ago.
+ if (handedOver) next!.apply()
+ }
+}
+
+/** One tracker on a viewport: what disposal needs to find and remove. */
+interface Tracker {
+ apply: () => void
+}
+
+/**
+ * The live trackers per viewport, newest last, plus the handlers the first
+ * of them displaced — restored when the last one leaves. A WeakMap because
+ * the real page has one visualViewport forever, but every test conjures its
+ * own; keying on the object keeps them from sharing a stack.
+ */
+interface TrackerStack {
+ stack: Tracker[]
+ prevResize: ViewportLike['onresize']
+ prevScroll: ViewportLike['onscroll']
+}
+
+const stacks = new WeakMap()
+
+function stackFor(viewport: ViewportLike): TrackerStack {
+ let rec = stacks.get(viewport)
+ if (rec === undefined) {
+ rec = { stack: [], prevResize: viewport.onresize, prevScroll: viewport.onscroll }
+ stacks.set(viewport, rec)
}
+ return rec
}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index cfac6f2..43f2b84 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -16,6 +16,7 @@ import { RemoteRoute } from '@/routes/remote'
import { SessionsRoute } from '@/routes/sessions'
import { SettingsRoute } from '@/routes/settings'
import { TerminalRoute } from '@/routes/terminal'
+import { ScratchProvider } from '@/scratch/provider'
import { NEW_SESSION_PATH, validateNewSessionSearch } from '@/sessions/new-session'
import { SwitcherProvider } from '@/switcher/provider'
@@ -123,7 +124,15 @@ const rootRoute = createRootRouteWithContext()({
not chosen one.
*/}
-
+ {/*
+ The scratch terminal rides beside the switcher for the same
+ reason the switcher is here: its chord has to answer on any
+ screen with a session, and its anchor is whatever session the
+ route says is on screen.
+ */}
+
+
+
)
diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx
index 216086f..071a324 100644
--- a/web/src/routes/sessions.tsx
+++ b/web/src/routes/sessions.tsx
@@ -27,6 +27,7 @@ import { keyOf, LOCAL_MACHINE_ID, type FleetSession, type MachineState } from '@
import { useRefetchOnFocus } from '@/hooks/use-refetch-on-focus'
import { takeCwd } from '@/lib/url'
import { cn } from '@/lib/utils'
+import { foldGroups } from '@/sessions/groups'
import { useOpenNewSession, type NewSessionOrigin } from '@/sessions/open-new-session'
import {
applyView,
@@ -46,6 +47,12 @@ import {
type SavedView,
} from '@/sessions/views-store'
+/**
+ * One empty list, module-wide, so a fleet that has not reported yet does not
+ * hand every useMemo below a fresh `[]` identity per render.
+ */
+const EMPTY_SESSIONS: FleetSession[] = []
+
/**
* The terminal's path, written out rather than imported from src/router.tsx.
*
@@ -388,9 +395,16 @@ export function SessionsRoute() {
// tab is looked at beats waiting out a stretched poll tick.
useRefetchOnFocus(useCallback(() => fleet.list(), [fleet]))
- const sessions = fleetState?.sessions ?? []
+ const merged = fleetState?.sessions ?? EMPTY_SESSIONS
const machines = fleetState?.machines ?? null
+ // Group members fold under their anchor: one row per group, wearing a pane
+ // count, rather than N rows for what a reader thinks of as one terminal. A
+ // member whose anchor is gone keeps its row — see foldGroups.
+ const grouped = useMemo(() => foldGroups(merged), [merged])
+ const sessions = grouped.rows
+ const panes = grouped.panes
+
const groups = useMemo(() => applyView(sessions, view), [sessions, view])
/** How many rows the view folded away for having ended. See hiddenExited. */
const hiddenEnded = useMemo(() => hiddenExited(sessions, view), [sessions, view])
@@ -752,6 +766,7 @@ export function SessionsRoute() {
{showTable && (
>(() => new Set())
+
+ // The URL keeps naming the session that was opened; the group is resolved
+ // from it. A member's URL resolves the same group, so a link to any pane
+ // opens the whole surface. It holds steady across a beat where the rows do not
+ // name the URL session yet — a Restart navigates to an id the next list
+ // has not delivered — so the group surface does not collapse and rebuild
+ // around a fact that is merely in flight. A genuinely different session
+ // (the ref remembers which id it answered for) starts from itself.
+ const anchorRef = useRef({ resolvedFor: sessionId, anchor: sessionId })
+ const own = rows.find((s) => s.id === sessionId)
+ const anchorId =
+ own !== undefined
+ ? anchorIdOf(own)
+ : anchorRef.current.resolvedFor === sessionId
+ ? anchorRef.current.anchor
+ : sessionId
+ anchorRef.current = { resolvedFor: sessionId, anchor: anchorId }
+
+ // Which members this view has seen alive. An exited member keeps its pane
+ // — the exit overlay is owed to whoever watched it die — but only for a
+ // death witnessed here: a fresh load must not dredge the retention
+ // window's corpses back onto the surface. Written during render, which is
+ // safe for a ref because adding to a set is idempotent.
+ const seenRunning = useRef(new Set())
+ for (const s of rows) if (s.state === 'running') seenRunning.current.add(s.id)
+
+ const members = groupMembers(rows, anchorId).filter(
+ (s) =>
+ !dismissed.has(s.id) &&
+ (s.id === sessionId || s.state !== 'exited' || seenRunning.current.has(s.id)),
+ )
+ const havePanes = members.length > 0
+ const panes = havePanes
+ ? members.map((s) => ({ id: s.id, label: displayName(s) }))
+ : [{ id: sessionId, label: '' }]
+
+ // The mobile tab in front. Falls back inside SessionGroup when it names a
+ // pane that has gone, and follows the URL when the URL moves.
+ const [active, setActive] = useState(sessionId)
+ useEffect(() => setActive(sessionId), [sessionId])
+ const isMobile = useIsMobile()
+
+ // The desktop arrangement: one split tree per tab, owned here rather than
+ // in SessionGroup because the verbs that change it (the menu rows, the
+ // chords) live here; persisted per group and per device. Every setTabTrees
+ // writes through saveTabs inside the updater, which is idempotent, so
+ // StrictMode's double-invoke costs a duplicate write and nothing else.
+ const storageKey = `flue.group.${deviceId}.${anchorId}`
+ const [tabTrees, setTabTrees] = useState(() => loadTabs(storageKey))
+ useEffect(() => setTabTrees(loadTabs(storageKey)), [storageKey])
+
+ const paneIds = panes.map((p) => p.id)
+
+ // The tabs follow the members: panes that closed leave their tree, tabs
+ // that emptied fold away, and a member that appeared without a recorded
+ // placement — a split made from another device — gets a tab of its own.
+ // Keyed on the id list's spelling, so the fleet's poll ticks cost nothing
+ // while nothing changes. Gated on real members: before the fleet's first
+ // answer the pane list is a placeholder for the URL session, and
+ // reconciling against that would prune a freshly loaded layout to one
+ // leaf — and persist the damage — on every reload.
+ const paneKey = paneIds.join(',')
+ useEffect(() => {
+ if (!havePanes) return
+ setTabTrees((t) => {
+ const next = reconcileTabs(t, paneKey.split(','))
+ if (next !== t) saveTabs(storageKey, next)
+ return next
+ })
+ }, [havePanes, paneKey, storageKey])
+
+ // Which pane last held the keyboard, for the chord to target: ⇧⌘D splits
+ // the pane being typed in, not the URL's. Written by a passive focus
+ // listener over the data attribute every Terminal pane carries.
+ const focusedPane = useRef(null)
+ useEffect(() => {
+ const onFocus = (e: FocusEvent) => {
+ const el = e.target instanceof Element ? e.target.closest('[data-flue-session]') : null
+ const id = el?.getAttribute('data-flue-session')
+ if (id != null && id !== '') focusedPane.current = id
+ }
+ window.addEventListener('focusin', onFocus)
+ return () => window.removeEventListener('focusin', onFocus)
+ }, [])
+ const goTo = useCallback(
+ (id: string) =>
+ void navigate({
+ to: '/d/$deviceId/s/$sessionId',
+ params: { deviceId, sessionId: id },
+ replace: true,
+ }),
+ [navigate, deviceId],
+ )
+
+ /*
+ * Split: another session in this group, in the directory of the pane that
+ * asked — and, for the two split verbs, placed beside that very pane in
+ * the tree, so ⇧⌘D over the right column of an A|B split stacks inside
+ * that column rather than rearranging the whole surface. "New tab" is the
+ * third verb: same spawn, tabs rendering. Click/chord-driven, like every
+ * spawn in this app — StrictMode runs mount effects twice and a spawning
+ * effect can only ever detach one of its shells. The ref is handed
+ * straight back; the pane mounts through the refreshed list.
+ */
+ const rowsRef = useRef(rows)
+ rowsRef.current = rows
+ const split = useCallback(
+ (paneId: string, cwd: string | null, verb: GroupLayout) => {
+ if (client === null) return
+ const reqId = client.spawn({
+ cwd: cwd ?? rowsRef.current.find((s) => s.id === paneId)?.cwd,
+ cols: 80,
+ rows: 24,
+ group: anchorId,
+ })
+ if (reqId === null) return
+ const offs: Array<() => void> = []
+ const settle = () => {
+ for (const off of offs) off()
+ }
+ offs.push(
+ client.onAttached((a) => {
+ if (a.reqId !== reqId) return
+ settle()
+ client.detach(a.ref)
+ // Place the new pane now, so the layout is settled before the
+ // refreshed list mounts it — the reconcile effect would otherwise
+ // guess and give it a tab of its own. A split lands beside the
+ // pane that asked, inside that pane's tab; a new tab is appended
+ // whole. splitInTabs declines when the target has meanwhile gone,
+ // and the reconcile pass then adopts the newcomer anyway.
+ setTabTrees((t) => {
+ const base = t.length === 0 ? [{ leaf: anchorId } as PaneTree] : t
+ const next =
+ verb === 'tabs'
+ ? [...base, { leaf: a.id } as PaneTree]
+ : splitInTabs(base, paneId, verb, a.id)
+ saveTabs(storageKey, next)
+ return next
+ })
+ // The pane appears when the rows say so; asking now is what makes
+ // that a beat rather than the fleet's next three-second poll.
+ client.list()
+ setActive(a.id)
+ }),
+ client.onError((e) => {
+ if (e.reqId === reqId) settle()
+ }),
+ client.onStatus((s) => {
+ if (s !== 'open') settle()
+ }),
+ )
+ },
+ [client, storageKey, anchorId],
+ )
+
+ // A divider settled: commit the ratio into its tab's tree and persist,
+ // once per drag.
+ const onRatio = useCallback(
+ (tab: number, path: TreePath, ratio: number) => {
+ setTabTrees((t) => {
+ const tree = t[tab]
+ if (tree === undefined) return t
+ const nextTree = withRatio(tree, path, ratio)
+ if (nextTree === tree) return t
+ const next = [...t]
+ next[tab] = nextTree
+ saveTabs(storageKey, next)
+ return next
+ })
+ },
+ [storageKey],
+ )
+
+ // The split chords: ⌘D / ⇧⌘D on a Mac, the Ctrl+Shift family elsewhere
+ // (lib/split-keys.ts). Capture phase for the switcher's reason — left to
+ // bubble, xterm turns the keystroke into bytes first. The target is the
+ // pane holding the keyboard, or the URL's when none does; everything is
+ // read through refs so the listener mounts once.
+ const splitRef = useRef(split)
+ splitRef.current = split
+ const paneIdsRef = useRef(paneIds)
+ paneIdsRef.current = paneIds
+ const urlPaneRef = useRef(sessionId)
+ urlPaneRef.current = sessionId
+ useEffect(() => {
+ if (!canMultiplex) return
+ const apple = isApplePlatform()
+ const onKey = (e: KeyboardEvent) => {
+ const dir = matchSplitChord(e, apple)
+ const newTab = dir === null && matchNewTabChord(e, apple)
+ if (dir === null && !newTab) return
+ e.preventDefault()
+ e.stopPropagation()
+ const focused = focusedPane.current
+ const target =
+ focused !== null && paneIdsRef.current.includes(focused) ? focused : urlPaneRef.current
+ splitRef.current(target, null, dir ?? 'tabs')
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => window.removeEventListener('keydown', onKey, true)
+ }, [canMultiplex])
+
+ /*
+ * Walking the tabs: ⌥⌘←/→ (Ctrl+Alt+←/→ off a Mac) steps to the
+ * neighbouring tab — of trees on a desktop, of panes on a phone. Purely
+ * client-side, so it works against any daemon, and mounted once with every
+ * moving part behind a ref.
+ */
+ const tabsRef = useRef(tabTrees)
+ tabsRef.current = tabTrees
+ const shownRef = useRef(sessionId)
+ const mobileRef = useRef(isMobile)
+ mobileRef.current = isMobile
+ useEffect(() => {
+ const apple = isApplePlatform()
+ const onKey = (e: KeyboardEvent) => {
+ const step = matchTabCycleChord(e, apple)
+ if (step === null) return
+ const ids = paneIdsRef.current
+ if (ids.length <= 1) return
+ e.preventDefault()
+ e.stopPropagation()
+ if (mobileRef.current) {
+ const at = Math.max(0, ids.indexOf(shownRef.current))
+ setActive(ids[(at + step + ids.length) % ids.length]!)
+ return
+ }
+ const tabs = tabsRef.current
+ if (tabs.length <= 1) return
+ const at = Math.max(0, tabOf(tabs, shownRef.current))
+ setActive(leafIds(tabs[(at + step + tabs.length) % tabs.length]!)[0]!)
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => window.removeEventListener('keydown', onKey, true)
+ }, [])
+
+ // Which pane wears the control strip. The chips belong to the surface,
+ // not to a pane — a strip on every pane is four chips times N, and one
+ // pinned to the URL pane floats mid-screen and dies with it. So they sit
+ // on whichever pane owns the surface's top-right corner — of the tab in
+ // front, on a desktop — and pass along when that pane goes.
+ const shownTab = panes.some((p) => p.id === active) ? active : panes[0]!.id
+ shownRef.current = shownTab
+ const activeTree = tabTrees[Math.max(0, tabOf(tabTrees, shownTab))]
+ const chipsPane = isMobile || activeTree === undefined ? shownTab : topRightLeaf(activeTree)
+
// A machine the fleet does not hold: never paired on this browser, or its
// pinned key gone. Said in a pill, the way the terminal answers a session
// the daemon has never heard of — though unlike that answer this one is
// provisional for a breath at boot, which is what the subscription above
// exists to notice.
if (client === null) return
- // Keyed by machine and session, so navigating between two sessions builds a
- // new terminal rather than feeding one emulator two sessions' scrollback.
- // The effect's dependency array would do this too; the key makes the state
- // React holds — the phase pill, the keyboard mode — reset with it.
return (
-
- void navigate({
- to: '/d/$deviceId/s/$sessionId',
- params: { deviceId, sessionId: id },
- replace: true,
- })
- }
- onClosed={() => void navigate({ to: '/', replace: true })}
- // This machine and this directory, because that is what a `+` inside a
- // session means. Both are only a prefill — the dialog offers the rest
- // of the fleet, and a session started from here need not be a sibling.
- onNewSession={(cwd) => setCreating({ machineId: deviceId, cwd: cwd ?? '' })}
+ split(shownTab, null, 'tabs') : undefined}
+ renderPane={(id, viewportInset, fit) => (
+ // Keyed by machine, session, inset and pinning, so navigating
+ // between two sessions — or the tab strip appearing above one, or
+ // a pane moving between a split and a lone rendering — builds a
+ // new terminal rather than feeding one emulator two sessions'
+ // scrollback. The key also resets the state React holds: the
+ // phase pill, the keyboard mode.
+ {
+ // Fired by the exit itself — there is no overlay any more. A
+ // session that was already over when this view opened is being
+ // *read*, which is what the daemon's exited-retention window
+ // is for, so only a shell seen alive here folds its pane away.
+ if (!seenRunning.current.has(id)) return
+ const remaining = paneIds.filter((p) => p !== id)
+ // The chord must never aim at this pane again. Its ref is
+ // cleared now rather than left to the next focusin, because a
+ // chord in the gap would read the dead pane's id and fall
+ // through to whatever the paneIds guard makes of it.
+ if (focusedPane.current === id) focusedPane.current = remaining[0] ?? null
+ if (remaining.length === 0) {
+ // replace: a dead session's URL is not worth a Back stop.
+ void navigate({ to: '/', replace: true })
+ return
+ }
+ setDismissed((prev) => new Set(prev).add(id))
+ if (id === sessionId) goTo(remaining[0]!)
+ else if (id === active) setActive(remaining[0]!)
+ }}
+ // This machine and this directory, because that is what a `+`
+ // inside a session means. Both are only a prefill — the dialog
+ // offers the rest of the fleet, and a session started from here
+ // need not be a sibling.
+ onNewSession={(cwd) => setCreating({ machineId: deviceId, cwd: cwd ?? '' })}
+ onSplit={canMultiplex ? (cwd, verb) => split(id, cwd, verb) : undefined}
+ />
+ )}
/>
, machineId: string): FleetSession[] {
+ const [rows, setRows] = useState([])
+ useEffect(() => {
+ setRows([])
+ return fleet.onFleet((sessions) => {
+ const mine = sessions.filter((s) => s.machineId === machineId)
+ // An empty answer while rows are held is kept out: the fleet nulls a
+ // machine's rows on every socket blip, and adopting that emptiness
+ // would collapse the group layout — unmounting and rebuilding every
+ // pane's emulator — for a one-second Wi-Fi hiccup. Holding the last
+ // known rows costs nothing real: a session that genuinely ended
+ // announces itself to its own pane (exit, or the not-found reply on
+ // reattach), and the rows refresh the moment the machine answers.
+ if (mine.length === 0) return
+ setRows((prev) => (sameRows(prev, mine) ? prev : mine))
+ })
+ }, [fleet, machineId])
+ return rows
+}
+
+/** Whether two row lists would render the same group view. */
+function sameRows(a: FleetSession[], b: FleetSession[]): boolean {
+ return a.length === b.length && a.every((s, i) => rowSig(s) === rowSig(b[i]!))
+}
+
+function rowSig(s: FleetSession): string {
+ return `${s.id}|${s.group ?? ''}|${s.state}|${s.cwd}|${displayName(s)}`
+}
+
+/** Whether `client` has announced a capability, kept current across welcomes. */
+function useHasCap(client: FlueClient | null, cap: string): boolean {
+ const [has, setHas] = useState(() => client?.hasCap(cap) ?? false)
+ const capRef = useRef(cap)
+ capRef.current = cap
+ useEffect(() => {
+ if (client === null) {
+ setHas(false)
+ return
+ }
+ setHas(client.hasCap(capRef.current))
+ return client.onWelcome(() => setHas(client.hasCap(capRef.current)))
+ }, [client])
+ return has
+}
+
/** What the new-session form needs off the fleet, and nothing else. */
interface FormFleet {
machines: Array<{ id: string; name: string }>
diff --git a/web/src/scratch/context.ts b/web/src/scratch/context.ts
new file mode 100644
index 0000000..bb06d85
--- /dev/null
+++ b/web/src/scratch/context.ts
@@ -0,0 +1,30 @@
+import { createContext, useContext } from 'react'
+
+/**
+ * What the rest of the app may ask of the scratch terminal. `toggle` is what
+ * the double-Ctrl chord does, exposed for the chip a phone needs — no Ctrl to
+ * tap twice there. `enabled` says whether the surface exists at all right
+ * now: there is a session on screen to anchor the scratch to, and its daemon
+ * has announced the `multiplex` capability.
+ */
+export interface Scratch {
+ toggle(): void
+ enabled: boolean
+}
+
+/**
+ * In its own module rather than beside the provider, and not by taste: the
+ * provider renders a Terminal inside its dialog, and the Terminal's control
+ * strip reads this context for its chip — a shared file is what keeps that
+ * from being an import cycle.
+ */
+export const ScratchContext = createContext(null)
+
+/**
+ * The scratch terminal's controls, from a component that cannot be sure the
+ * provider is mounted. The no-op default is for tests that mount the terminal
+ * alone, exactly as useSwitcher answers them.
+ */
+export function useScratch(): Scratch {
+ return useContext(ScratchContext) ?? { toggle: () => {}, enabled: false }
+}
diff --git a/web/src/scratch/double-ctrl.test.ts b/web/src/scratch/double-ctrl.test.ts
new file mode 100644
index 0000000..980fa9c
--- /dev/null
+++ b/web/src/scratch/double-ctrl.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest'
+
+import { createDoubleCtrl, type DoubleCtrl } from './double-ctrl'
+
+/** Drive the detector like a keyboard would, with a controllable clock. */
+function rig(windowMs = 350) {
+ let t = 0
+ const chord = createDoubleCtrl({ windowMs, now: () => t })
+ const at = (ms: number) => {
+ t = ms
+ }
+ const down = (key: string, repeat = false) => chord.keydown({ key, repeat })
+ const up = (key: string) => chord.keyup({ key })
+ return { chord, at, down, up }
+}
+
+/** One bare Ctrl tap: press then release, both at the current clock. */
+function tap(r: ReturnType): boolean {
+ r.down('Control')
+ return r.up('Control')
+}
+
+describe('createDoubleCtrl', () => {
+ it('fires on two bare taps inside the window', () => {
+ const r = rig()
+ expect(tap(r)).toBe(false)
+ r.at(200)
+ expect(tap(r)).toBe(true)
+ })
+
+ it('does not fire when the second press comes too late', () => {
+ const r = rig(350)
+ tap(r)
+ r.at(400)
+ expect(tap(r)).toBe(false)
+ // But that late tap starts a fresh pair.
+ r.at(500)
+ expect(tap(r)).toBe(true)
+ })
+
+ it('never counts a real chord: Ctrl+C ends with a Ctrl keyup that must not be half a tap', () => {
+ const r = rig()
+ r.down('Control')
+ r.down('c')
+ r.up('c')
+ expect(r.up('Control')).toBe(false)
+ // One genuine tap after the chord is only ever the first of a pair.
+ r.at(100)
+ expect(tap(r)).toBe(false)
+ r.at(200)
+ expect(tap(r)).toBe(true)
+ })
+
+ it('is spoiled by any key between the taps', () => {
+ const r = rig()
+ tap(r)
+ r.down('a')
+ r.up('a')
+ r.at(100)
+ expect(tap(r)).toBe(false)
+ })
+
+ it('cancels a second press that turns into a chord', () => {
+ const r = rig()
+ tap(r)
+ r.at(100)
+ r.down('Control')
+ r.down('c') // tap, then Ctrl+C: an interrupt, not a chord completion
+ r.up('c')
+ expect(r.up('Control')).toBe(false)
+ })
+
+ it('ignores auto-repeat of a held Ctrl', () => {
+ const r = rig()
+ tap(r)
+ r.at(100)
+ r.down('Control')
+ r.down('Control', true)
+ r.down('Control', true)
+ expect(r.up('Control')).toBe(true)
+ })
+
+ it('proves nothing from a release it never saw pressed', () => {
+ const r = rig()
+ tap(r)
+ r.at(100)
+ // Focus came back mid-hold: keyup with no keydown behind it.
+ expect(r.up('Control')).toBe(false)
+ })
+
+ it('forgets everything on reset', () => {
+ const r = rig()
+ tap(r)
+ r.chord.reset()
+ r.at(100)
+ expect(tap(r)).toBe(false)
+ r.at(200)
+ expect(tap(r)).toBe(true)
+ })
+
+ it('keeps firing on later pairs', () => {
+ const r = rig()
+ tap(r)
+ r.at(100)
+ expect(tap(r)).toBe(true)
+ r.at(300)
+ tap(r)
+ r.at(400)
+ expect(tap(r)).toBe(true)
+ })
+})
+
+// The type is exported for the provider; keep the import honest.
+const _typecheck: DoubleCtrl = createDoubleCtrl()
+void _typecheck
diff --git a/web/src/scratch/double-ctrl.ts b/web/src/scratch/double-ctrl.ts
new file mode 100644
index 0000000..d5688fa
--- /dev/null
+++ b/web/src/scratch/double-ctrl.ts
@@ -0,0 +1,107 @@
+/*
+ * The double-Ctrl chord: two bare Ctrl press-and-release taps, close
+ * together, with no other key anywhere between them.
+ *
+ * A bare Ctrl tap is the one modifier gesture that cannot collide with
+ * terminal input — a lone modifier sends nothing to the pty — and it is not
+ * on any browser's reserved list (#64 is about Ctrl+W and friends, which are
+ * chords). The hazard is the real chord: Ctrl+C ends with a Ctrl keyup, and
+ * counting that release as half a tap would open the scratch terminal on
+ * every second interrupt. Hence "bare": a tap is spoiled by any other key
+ * going down while Ctrl is held, and the gap between taps is spoiled by any
+ * other key at all.
+ *
+ * This is a pure state machine over keydown/keyup so the timing rules are
+ * testable without a DOM. The caller wires it to window listeners in the
+ * capture phase — the same reason the switcher's chords run there: left to
+ * bubble, xterm's handler would never let the events out of the terminal.
+ */
+
+export interface DoubleCtrlOptions {
+ /**
+ * How close the two taps must be: the second press within this many
+ * milliseconds of the first release. Roomier than a double-click default
+ * because two taps of the same finger on the same key are slower than two
+ * clicks of a button.
+ */
+ windowMs?: number
+ /** The clock, for tests. */
+ now?: () => number
+}
+
+export interface DoubleCtrl {
+ /** Feed a keydown. True when this event completed the chord. */
+ keydown(e: Pick): boolean
+ /** Feed a keyup. True when this event completed the chord. */
+ keyup(e: Pick): boolean
+ /** Forget everything — call when the window loses focus and releases go missing. */
+ reset(): void
+}
+
+const DEFAULT_WINDOW_MS = 350
+
+export function createDoubleCtrl(opts: DoubleCtrlOptions = {}): DoubleCtrl {
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS
+ const now = opts.now ?? (() => performance.now())
+
+ /** Ctrl is currently held. */
+ let held = false
+ /** Another key went down while this Ctrl was held — it is a chord. */
+ let chorded = false
+ /** When the first bare tap's release landed, or null when no tap stands. */
+ let tappedAt: number | null = null
+ /**
+ * The standing second press: Ctrl went down in time and bare so far, and
+ * only its release remains. Kept apart from `tappedAt` so a second press
+ * that turns into a chord (Ctrl tap, then Ctrl+C) cancels cleanly.
+ */
+ let arming = false
+
+ const reset = () => {
+ held = false
+ chorded = false
+ tappedAt = null
+ arming = false
+ }
+
+ return {
+ keydown(e) {
+ if (e.key === 'Control') {
+ if (e.repeat) return false
+ held = true
+ chorded = false
+ arming = tappedAt !== null && now() - tappedAt <= windowMs
+ return false
+ }
+ // Any other key: a held Ctrl becomes a chord, and a standing tap is
+ // spoiled — "no other key in between" is what keeps Ctrl+C, C, Ctrl+C
+ // from reading as taps around a keystroke.
+ chorded = true
+ tappedAt = null
+ arming = false
+ return false
+ },
+
+ keyup(e) {
+ if (e.key !== 'Control') return false
+ // A release with no press behind it — focus returned mid-hold, or the
+ // browser ate the keydown — proves nothing.
+ if (!held) return false
+ held = false
+ if (chorded) {
+ chorded = false
+ tappedAt = null
+ arming = false
+ return false
+ }
+ if (arming) {
+ reset()
+ return true
+ }
+ tappedAt = now()
+ return false
+ },
+
+ reset,
+ }
+}
diff --git a/web/src/scratch/provider.test.tsx b/web/src/scratch/provider.test.tsx
new file mode 100644
index 0000000..0de3ff9
--- /dev/null
+++ b/web/src/scratch/provider.test.tsx
@@ -0,0 +1,151 @@
+import { act, render, screen } from '@testing-library/react'
+import { RouterProvider } from '@tanstack/react-router'
+import { describe, expect, it } from 'vitest'
+
+import { FleetClient } from '@/fleet/fleet'
+import { FleetProvider } from '@/fleet/provider'
+import { createFlueRouter } from '@/router'
+import type { SessionInfo } from '@/client/protocol'
+import { fakeClient, type FakeSocket } from '@/testing/socket'
+
+/** One session as a daemon reports it, with the dull fields filled in. */
+function row(over: Partial & { id: string }): SessionInfo {
+ return {
+ title: '',
+ name: '',
+ tags: [],
+ pinned: false,
+ cwd: '/home/karn',
+ cmd: ['zsh'],
+ state: 'running',
+ exitCode: 0,
+ cols: 80,
+ rows: 24,
+ createdAt: '2026-01-01T00:00:00Z',
+ lastActive: '2026-01-01T00:00:00Z',
+ ...over,
+ }
+}
+
+/**
+ * The real router at `path` over one scripted machine, exactly as the
+ * switcher's tests mount it: the scratch provider lives in the root route,
+ * and the chord listener is the part worth testing through the real tree.
+ */
+async function mountApp(path: string) {
+ window.history.replaceState(null, '', path)
+ const router = createFlueRouter()
+ await router.load()
+ const local = fakeClient()
+ const fleet = new FleetClient([
+ { id: 'local', name: 'macbook', client: local.client, pinned: false },
+ ])
+ let view!: ReturnType
+ await act(async () => {
+ view = render(
+
+
+ ,
+ )
+ })
+ return { ...view, router, local }
+}
+
+/** Open the machine's first socket speaking `multiplex`, and list `sessions`. */
+function connect(machine: { sockets: FakeSocket[] }, sessions: SessionInfo[]) {
+ const sock = machine.sockets[0]!
+ act(() => {
+ if (!sock.opened) sock.open()
+ sock.emitControl({
+ type: 'welcome',
+ daemonId: 'local',
+ host: 'macbook',
+ ver: '0.5.0',
+ caps: ['multiplex'],
+ })
+ sock.emitControl({ type: 'sessions', sessions })
+ })
+ return sock
+}
+
+/** Two bare Ctrl taps — the scratch chord, played on the window. */
+function tapCtrlTwice() {
+ act(() => {
+ for (let i = 0; i < 2; i++) {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control', bubbles: true }))
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Control', bubbles: true }))
+ }
+ })
+}
+
+describe('ScratchProvider', () => {
+ it('adopts the running scratch the chord finds and opens the modal', async () => {
+ const { local } = await mountApp('/d/local/s/s1')
+ const sock = connect(local, [row({ id: 's1' })])
+
+ tapCtrlTwice()
+ expect(sock.ofType('list').length).toBeGreaterThan(0)
+ act(() => {
+ sock.emitControl({
+ type: 'sessions',
+ sessions: [
+ row({ id: 's1' }),
+ row({ id: 'sc1', group: 's1', ephemeral: true }),
+ ],
+ })
+ })
+
+ expect(screen.getByText('Scratch terminal')).toBeTruthy()
+ expect(sock.ofType('spawn')).toEqual([])
+ })
+
+ it('stays closed when the answer arrives after navigating to another session', async () => {
+ const { router, local } = await mountApp('/d/local/s/s1')
+ const sock = connect(local, [row({ id: 's1' }), row({ id: 's2' })])
+
+ tapCtrlTwice()
+ // The route moves while the list round-trip is out: the scratch that
+ // resolves belongs to s1, and popping it over s2 would be a modal about
+ // a session that is no longer on screen.
+ await act(async () => {
+ await router.navigate({
+ to: '/d/$deviceId/s/$sessionId',
+ params: { deviceId: 'local', sessionId: 's2' },
+ replace: true,
+ })
+ })
+ act(() => {
+ sock.emitControl({
+ type: 'sessions',
+ sessions: [
+ row({ id: 's1' }),
+ row({ id: 's2' }),
+ row({ id: 'sc1', group: 's1', ephemeral: true }),
+ ],
+ })
+ })
+
+ expect(screen.queryByText('Scratch terminal')).toBeNull()
+ expect(sock.ofType('spawn')).toEqual([])
+ })
+
+ it('declines to spawn when the anchor is no longer a running session', async () => {
+ const { local } = await mountApp('/d/local/s/s1')
+ const sock = connect(local, [row({ id: 's1' })])
+
+ tapCtrlTwice()
+ // The parent exited between the chord and the answer. A scratch grouped
+ // under an exited parent is closed by the daemon's next sweep within
+ // seconds of being born — spawning it is a shell with a death warrant,
+ // in the daemon's default directory no less.
+ act(() => {
+ sock.emitControl({
+ type: 'sessions',
+ sessions: [row({ id: 's1', state: 'exited', exitCode: 0 })],
+ })
+ })
+
+ expect(sock.ofType('spawn')).toEqual([])
+ expect(screen.queryByText('Scratch terminal')).toBeNull()
+ })
+})
diff --git a/web/src/scratch/provider.tsx b/web/src/scratch/provider.tsx
new file mode 100644
index 0000000..2579143
--- /dev/null
+++ b/web/src/scratch/provider.tsx
@@ -0,0 +1,355 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { useRouterState } from '@tanstack/react-router'
+import { Dialog } from 'radix-ui'
+import { SquareTerminalIcon, XIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+
+import type { FlueClient } from '@/client/client'
+import type { SessionInfo } from '@/client/protocol'
+import { FlueClientContext } from '@/client/provider'
+import { Terminal } from '@/components/terminal'
+import { useFleet } from '@/fleet/provider'
+import { LOCAL_MACHINE_ID } from '@/fleet/types'
+import { useIsMobile } from '@/hooks/use-mobile'
+import { createDoubleCtrl } from './double-ctrl'
+import { ScratchContext } from './context'
+
+/** 80x24 is a starting point, not a decision; the terminal corrects it. */
+const SPAWN_COLS = 80
+const SPAWN_ROWS = 24
+
+/** The modal header's height in px (h-11 on mobile below), for the viewport pinning. */
+const HEADER_PX = 44
+
+/** What the modal is showing: which machine's client, whose scratch, and whose cwd it started in. */
+interface OpenScratch {
+ machineId: string
+ sessionId: string
+ parentId: string
+}
+
+/**
+ * The scratch terminal: double-tap Ctrl over a session and a modal opens with
+ * a shell on the same machine, in that session's directory.
+ *
+ * The shell is an `ephemeral` session grouped under the one on screen, and
+ * that grouping is its whole lifecycle: dismissing the modal only detaches,
+ * so a dev server started in it keeps serving; tapping the chord again finds
+ * the same session still running and reattaches; and the daemon closes it
+ * when the parent session ends (spec/protocol.md, "Groups and ephemeral
+ * sessions"). "Keep" is the way out of that bargain — it clears the flag and
+ * the scratch becomes an ordinary member of the group, a split pane from the
+ * next render on.
+ *
+ * Mounted above the terminal route like the switcher, and for the same
+ * reason: the chord has to work wherever a session is on screen. It renders
+ * nothing and listens for two keys while closed.
+ */
+export function ScratchProvider({ children }: { children: ReactNode }) {
+ const fleet = useFleet()
+ const isMobile = useIsMobile()
+ const [open, setOpen] = useState(null)
+
+ // Which session the chord would anchor to: the deepest route match, read
+ // the way the switcher reads it — a param hook here would answer for the
+ // root route, which has no params on any screen.
+ const params = useRouterState({
+ select: (s) =>
+ (s.matches[s.matches.length - 1]?.params ?? {}) as {
+ deviceId?: string
+ sessionId?: string
+ },
+ })
+ const machineId = params.deviceId ?? LOCAL_MACHINE_ID
+ const parentId = params.sessionId ?? null
+
+ // Whether the chord and the chip do anything right now: a session on
+ // screen, its machine reachable, and its daemon speaking `multiplex`. The
+ // cap arrives on the welcome, so this listens rather than reads once.
+ const [enabled, setEnabled] = useState(false)
+ useEffect(() => {
+ if (parentId === null) {
+ setEnabled(false)
+ return
+ }
+ // Re-resolved on every fleet reshaping, not read once: a direct load of
+ // a remote machine's session renders before the fleet has adopted its
+ // remote sources, so the first look legitimately finds no client — and a
+ // welcome subscription is only worth holding on the client that exists.
+ const offs: Array<() => void> = []
+ let offWelcome: (() => void) | null = null
+ let heard: unknown = null
+ const recompute = () => {
+ const client = fleet.clientFor(machineId)
+ setEnabled(client !== null && client.hasCap('multiplex'))
+ if (client !== null && heard !== client) {
+ heard = client
+ offWelcome?.()
+ offWelcome = client.onWelcome(recompute)
+ }
+ }
+ recompute()
+ offs.push(fleet.onFleet(recompute))
+ return () => {
+ for (const off of offs) off()
+ offWelcome?.()
+ }
+ }, [fleet, machineId, parentId])
+
+ // One resolution in flight at a time: a chord tapped thrice while the list
+ // round-trip is out must not spawn three scratches.
+ const resolving = useRef(false)
+ // Where the user is *now*, for a resolution that started somewhere else:
+ // the list round-trip can outlive the route it was asked from, and a modal
+ // that pops over the next session carries a scratch about the last one.
+ const routeRef = useRef({ machineId, parentId })
+ routeRef.current = { machineId, parentId }
+ const openRef = useRef(open)
+ openRef.current = open
+
+ const dismiss = useCallback(() => {
+ // Detach only — the Terminal's unmount does that by itself. The shell
+ // runs on; that is the point.
+ setOpen(null)
+ }, [])
+
+ const toggle = useCallback(() => {
+ if (openRef.current !== null) {
+ setOpen(null)
+ return
+ }
+ if (parentId === null || resolving.current) return
+ const client = fleet.clientFor(machineId)
+ if (client === null || !client.hasCap('multiplex')) return
+
+ // Resolve against the daemon's own list rather than the fleet's rows,
+ // which hide ephemeral sessions on purpose: the running scratch this
+ // parent already has is exactly what those rows will not say.
+ resolving.current = true
+ const offs: Array<() => void> = []
+ const settle = () => {
+ resolving.current = false
+ for (const off of offs) off()
+ }
+
+ const anchor = parentId
+ // True while the user is still where the chord was tapped. Checked at
+ // every landing, not once: each answer arrives on its own tick, and the
+ // route may have moved during any of the waits.
+ const stillHere = () =>
+ routeRef.current.parentId === anchor && routeRef.current.machineId === machineId
+ const adopt = (rows: SessionInfo[]) => {
+ const existing = rows.find(
+ (s) => s.group === anchor && s.ephemeral === true && s.state === 'running',
+ )
+ if (existing !== undefined) {
+ settle()
+ if (!stillHere()) return
+ setOpen({ machineId, sessionId: existing.id, parentId: anchor })
+ return
+ }
+ const parent = rows.find((s) => s.id === anchor)
+ if (parent === undefined || parent.state !== 'running' || !stillHere()) {
+ // No running parent to belong to (the daemon's sweep would close the
+ // newborn within seconds anyway), or the user has already left.
+ settle()
+ return
+ }
+ const cwd = parent.cwd
+ const reqId = client.spawn({
+ cwd,
+ cols: SPAWN_COLS,
+ rows: SPAWN_ROWS,
+ group: anchor,
+ ephemeral: true,
+ })
+ if (reqId === null) {
+ settle()
+ return
+ }
+ offs.push(
+ client.onAttached((a) => {
+ if (a.reqId !== reqId) return
+ settle()
+ // Hand the ref straight back — the modal's Terminal attaches for
+ // itself, exactly as every navigation target does.
+ client.detach(a.ref)
+ // Left during the spawn: the scratch exists and keeps running
+ // under its parent — the next chord over that session adopts it —
+ // but it is not popped over whatever screen the user is on now.
+ if (!stillHere()) return
+ setOpen({ machineId, sessionId: a.id, parentId: anchor })
+ }),
+ client.onError((e) => {
+ if (e.reqId === reqId) settle()
+ }),
+ )
+ }
+
+ let answered = false
+ offs.push(
+ client.onSessions((rows) => {
+ // The first list after the ask answers the "is one already running"
+ // question; later ones are other screens' polls.
+ if (answered) return
+ answered = true
+ adopt(rows)
+ }),
+ // Replies do not survive their socket.
+ client.onStatus((s) => {
+ if (s !== 'open') settle()
+ }),
+ )
+ client.list()
+ }, [fleet, machineId, parentId])
+
+ const toggleRef = useRef(toggle)
+ toggleRef.current = toggle
+ const enabledRef = useRef(enabled)
+ enabledRef.current = enabled
+
+ /*
+ * The chord, in the capture phase for the switcher's reason: left to
+ * bubble, xterm turns keys into bytes before anything above it runs. A
+ * bare Ctrl tap sends nothing to the pty, so listening costs the terminal
+ * nothing; the detector (scratch/double-ctrl.ts) is what keeps a real
+ * Ctrl+C chord from counting as half a tap.
+ *
+ * Escape, while the modal is up, dismisses — also captured, and stopped,
+ * so it cannot double as input to the scratch shell it is closing.
+ */
+ useEffect(() => {
+ const chord = createDoubleCtrl()
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (openRef.current !== null && e.key === 'Escape') {
+ e.preventDefault()
+ e.stopPropagation()
+ chord.reset()
+ setOpen(null)
+ return
+ }
+ if (chord.keydown(e) && (enabledRef.current || openRef.current !== null)) {
+ toggleRef.current()
+ }
+ }
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (chord.keyup(e) && (enabledRef.current || openRef.current !== null)) {
+ toggleRef.current()
+ }
+ }
+ const onFocusLost = () => chord.reset()
+ // The focus-loss event's name is spelled in two halves because it is also
+ // a Tailwind utility name, and a quoted string of it in any scanned
+ // source compiles a stray rule into the shipped stylesheet — see the
+ // scanner notes at the top of src/styles.css and styles.build.test.ts.
+ const focusLost = 'blu' + 'r'
+ window.addEventListener('keydown', onKeyDown, true)
+ window.addEventListener('keyup', onKeyUp, true)
+ window.addEventListener(focusLost, onFocusLost)
+ return () => {
+ window.removeEventListener('keydown', onKeyDown, true)
+ window.removeEventListener('keyup', onKeyUp, true)
+ window.removeEventListener(focusLost, onFocusLost)
+ }
+ }, [])
+
+ const client = open !== null ? fleet.clientFor(open.machineId) : null
+
+ const keep = useCallback(() => {
+ if (openRef.current === null) return
+ const c = fleet.clientFor(openRef.current.machineId)
+ // Clearing the flag is the promotion: the daemon moves it to ordinary
+ // retention and stops tying it to the parent, and the refreshed list
+ // surfaces it as a member — a pane in the group view — now rather than
+ // on the fleet's next three-second poll.
+ c?.update({ id: openRef.current.sessionId, ephemeral: false })
+ c?.list()
+ setOpen(null)
+ }, [fleet])
+
+ // Memoised, and load-bearing rather than tidy: every terminal's control
+ // strip reads this context for its chip, and a fresh value object per
+ // provider render would re-render every mounted terminal on every
+ // navigation this provider sees.
+ const scratch = useMemo(() => ({ toggle, enabled }), [toggle, enabled])
+
+ return (
+
+ {children}
+ !o && dismiss()}>
+
+ {/* A dim and nothing frosted: the session underneath is what the scratch
+ is *about*, and frosting it over reads as leaving the page. */}
+
+ e.preventDefault()}
+ // Autofocus is refused so the terminal keeps the keyboard it
+ // takes for itself on mount. Radix would otherwise focus the
+ // first tabbable — the Keep button — and the first Enter typed
+ // at the shell would silently promote the scratch instead.
+ onOpenAutoFocus={(e) => e.preventDefault()}
+ // The switcher's box, deliberately: same width, same anchor
+ // height, same popover surface and hairline — the two overlays a
+ // chord can summon should read as siblings.
+ className={
+ 'fixed inset-0 z-50 flex flex-col overflow-hidden bg-popover text-popover-foreground outline-none ' +
+ 'sm:inset-auto sm:top-[12vh] sm:left-1/2 sm:h-[64vh] sm:w-[56rem] sm:max-w-[calc(100vw-2rem)] sm:-translate-x-1/2 ' +
+ 'sm:rounded-lg sm:shadow-high sm:ring-1 sm:ring-hairline'
+ }
+ >
+ {/*
+ The frame is one slim header: hairline, a quiet title, the two
+ verbs. The Terminal below renders minimal chrome, so nothing in
+ it navigates away from under the dialog.
+ */}
+
+
+
+
+
+ )
+}
diff --git a/web/src/sessions/groups.test.ts b/web/src/sessions/groups.test.ts
new file mode 100644
index 0000000..c32aeb6
--- /dev/null
+++ b/web/src/sessions/groups.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+
+import type { SessionInfo } from '@/client/protocol'
+import type { FleetSession } from '@/fleet/types'
+import { anchorIdOf, foldGroups, groupMembers } from './groups'
+
+function row(over: Partial & { id: string }): FleetSession {
+ return {
+ title: '',
+ name: '',
+ tags: [],
+ pinned: false,
+ cwd: '/home/karn',
+ cmd: ['zsh', '-l'],
+ state: 'running',
+ exitCode: 0,
+ cols: 80,
+ rows: 24,
+ createdAt: '2026-08-01T10:00:00Z',
+ lastActive: '2026-08-01T10:00:00Z',
+ machineId: 'local',
+ machineName: 'this machine',
+ ...over,
+ }
+}
+
+describe('anchorIdOf', () => {
+ it('is the session itself when it stands alone', () => {
+ expect(anchorIdOf({ id: 's1' })).toBe('s1')
+ expect(anchorIdOf({ id: 's1', group: '' })).toBe('s1')
+ })
+
+ it('is the anchor for a member, so any member URL opens the group', () => {
+ expect(anchorIdOf({ id: 's2', group: 's1' })).toBe('s1')
+ })
+})
+
+describe('groupMembers', () => {
+ const anchor = row({ id: 'a' })
+ const early = row({ id: 'm1', group: 'a', createdAt: '2026-08-01T10:01:00Z' })
+ const late = row({ id: 'm2', group: 'a', createdAt: '2026-08-01T10:02:00Z' })
+
+ it('yields the anchor first, then members oldest split first', () => {
+ const got = groupMembers([late, anchor, early] as SessionInfo[], 'a')
+ expect(got.map((s) => s.id)).toEqual(['a', 'm1', 'm2'])
+ })
+
+ it('keeps an exited member — its pane owes the reader an exit overlay', () => {
+ const ended = row({ id: 'm1', group: 'a', state: 'exited' })
+ const got = groupMembers([anchor, ended] as SessionInfo[], 'a')
+ expect(got.map((s) => s.id)).toEqual(['a', 'm1'])
+ })
+
+ it('excludes a scratch terminal — the modal owns it, never the layout', () => {
+ const scratch = row({ id: 'sc', group: 'a', ephemeral: true })
+ const got = groupMembers([anchor, scratch, early] as SessionInfo[], 'a')
+ expect(got.map((s) => s.id)).toEqual(['a', 'm1'])
+ })
+
+ it('survives a missing anchor: the members are the group now', () => {
+ const got = groupMembers([late, early] as SessionInfo[], 'a')
+ expect(got.map((s) => s.id)).toEqual(['m1', 'm2'])
+ })
+
+ it('leaves strangers out', () => {
+ const other = row({ id: 'x', group: 'b' })
+ const plain = row({ id: 'y' })
+ const got = groupMembers([anchor, other, plain] as SessionInfo[], 'a')
+ expect(got.map((s) => s.id)).toEqual(['a'])
+ })
+})
+
+describe('foldGroups', () => {
+ it('folds members under their anchor and counts the panes', () => {
+ const anchor = row({ id: 'a' })
+ const m1 = row({ id: 'm1', group: 'a' })
+ const m2 = row({ id: 'm2', group: 'a' })
+ const plain = row({ id: 'p' })
+
+ const { rows, panes } = foldGroups([anchor, m1, plain, m2])
+ expect(rows.map((s) => s.id)).toEqual(['a', 'p'])
+ expect(panes.get('local/a')).toBe(3)
+ expect(panes.has('local/p')).toBe(false)
+ })
+
+ it('keeps a member whose anchor is gone — no session may vanish', () => {
+ const orphan = row({ id: 'm1', group: 'gone' })
+ const { rows, panes } = foldGroups([orphan])
+ expect(rows.map((s) => s.id)).toEqual(['m1'])
+ expect(panes.size).toBe(0)
+ })
+
+ it('does not fold a running member under an exited anchor', () => {
+ // The list hides exited rows by default, so a live shell folded under a
+ // dead anchor would vanish from the list, the search and the bulk bar
+ // for the whole exited-retention window.
+ const deadAnchor = row({ id: 'a', state: 'exited' })
+ const live = row({ id: 'm1', group: 'a' })
+ const alsoDead = row({ id: 'm2', group: 'a', state: 'exited' })
+ const { rows, panes } = foldGroups([deadAnchor, live, alsoDead])
+ expect(rows.map((s) => s.id)).toEqual(['a', 'm1'])
+ // The exited member still folds — it is as over as its anchor.
+ expect(panes.get('local/a')).toBe(2)
+ })
+
+ it('folds per machine: the same ids on two machines are two groups', () => {
+ const anchorHere = row({ id: 'a' })
+ const memberThere = row({ id: 'm1', group: 'a', machineId: 'remote', machineName: 'far' })
+ const { rows } = foldGroups([anchorHere, memberThere])
+ // The remote member's anchor is on another machine, so it must not fold
+ // under the local one.
+ expect(rows.map((s) => s.id)).toEqual(['a', 'm1'])
+ })
+})
diff --git a/web/src/sessions/groups.ts b/web/src/sessions/groups.ts
new file mode 100644
index 0000000..80851ff
--- /dev/null
+++ b/web/src/sessions/groups.ts
@@ -0,0 +1,86 @@
+/*
+ * Session groups, client side.
+ *
+ * The daemon records one optional fact — `group`, the id of the session a
+ * member was split from — and everything else about groups is a rendering
+ * decision made here: which sessions share a surface, what order the panes
+ * come in, and what the sessions list folds away. Groups are flat by
+ * construction: a member's `group` names its anchor and nothing nests, so
+ * every question below is answered by one field read, never a walk.
+ */
+
+import type { SessionInfo } from '@/client/protocol'
+import { keyOf, type FleetSession } from '@/fleet/types'
+
+/**
+ * The id of the group a session belongs to: its anchor's, or its own when it
+ * stands alone. This is what the terminal route resolves a URL through, so a
+ * link to any member opens the whole group.
+ */
+export function anchorIdOf(s: Pick): string {
+ return s.group !== undefined && s.group !== '' ? s.group : s.id
+}
+
+/**
+ * The sessions that share one group surface, panes-in-order: the anchor
+ * first when it still exists, then members oldest split first, so panes keep
+ * their places as the list refreshes around them.
+ *
+ * Exited members stay in — a pane whose process ended shows its exit
+ * overlay rather than vanishing mid-read — and ephemeral ones stay out: a
+ * scratch terminal belongs to the modal, never to the pane layout. An anchor
+ * that is gone (closed and reaped) simply yields the members, which is what
+ * lets a group survive its anchor.
+ */
+export function groupMembers(rows: T[], anchorId: string): T[] {
+ const anchor = rows.filter((s) => s.id === anchorId && s.ephemeral !== true)
+ const members = rows
+ .filter((s) => s.group === anchorId && s.id !== anchorId && s.ephemeral !== true)
+ .sort((a, b) =>
+ a.createdAt === b.createdAt
+ ? a.id.localeCompare(b.id)
+ : a.createdAt.localeCompare(b.createdAt),
+ )
+ return [...anchor, ...members]
+}
+
+/** What foldGroups hands back: the rows left on show, and each anchor's pane count. */
+export interface FoldedSessions {
+ rows: FleetSession[]
+ /** keyOf(anchor row) -> total panes in its group, only when more than one. */
+ panes: Map
+}
+
+/**
+ * Fold group members under their anchors for the sessions list: one row per
+ * group, wearing a pane count, rather than N rows for what a reader thinks
+ * of as one terminal.
+ *
+ * Machine-scoped, because two daemons mint ids with no knowledge of each
+ * other — a member folds only under an anchor on its own machine. A member
+ * whose anchor is not in the list at all (closed, reaped, or filtered away
+ * upstream) keeps its row: a session must never vanish from every surface at once.
+ */
+export function foldGroups(rows: FleetSession[]): FoldedSessions {
+ const anchors = new Map()
+ for (const s of rows) {
+ if (s.group === undefined || s.group === '') anchors.set(keyOf(s), s)
+ }
+
+ const out: FleetSession[] = []
+ const panes = new Map()
+ for (const s of rows) {
+ const anchorKey = `${s.machineId}/${anchorIdOf(s)}`
+ const anchor = s.group !== undefined && s.group !== '' ? anchors.get(anchorKey) : undefined
+ // A running member never folds under an exited anchor. The list hides
+ // exited rows by default, and a fold that put a live shell behind a
+ // hidden corpse would be that shell gone from the list, the search and
+ // the bulk bar for the whole exited-retention window.
+ if (anchor === undefined || (anchor.state === 'exited' && s.state !== 'exited')) {
+ out.push(s)
+ continue
+ }
+ panes.set(anchorKey, (panes.get(anchorKey) ?? 1) + 1)
+ }
+ return { rows: out, panes }
+}
diff --git a/web/src/sessions/pane-tree.test.ts b/web/src/sessions/pane-tree.test.ts
new file mode 100644
index 0000000..71bc214
--- /dev/null
+++ b/web/src/sessions/pane-tree.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ leaf,
+ leafIds,
+ parseTree,
+ prune,
+ reconcile,
+ reconcileTabs,
+ splitInTabs,
+ splitLeaf,
+ tabOf,
+ topRightLeaf,
+ withRatio,
+ type PaneTree,
+} from './pane-tree'
+
+const AB: PaneTree = { split: 'row', ratio: 0.5, a: leaf('a'), b: leaf('b') }
+
+describe('splitLeaf', () => {
+ it('replaces the target leaf with a split of it and the newcomer', () => {
+ const t = splitLeaf(leaf('a'), 'a', 'row', 'b')
+ expect(t).toEqual(AB)
+ })
+
+ it('splits inside one side without touching the other — the ⇧⌘D case', () => {
+ // A|B, then split B downward: A stays a full-height column, B becomes a
+ // stack. The whole surface must not change axis.
+ const t = splitLeaf(AB, 'b', 'column', 'c')
+ expect(t).toEqual({
+ split: 'row',
+ ratio: 0.5,
+ a: leaf('a'),
+ b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') },
+ })
+ })
+
+ it('returns the same tree when the target is not in it', () => {
+ expect(splitLeaf(AB, 'missing', 'row', 'c')).toBe(AB)
+ })
+})
+
+describe('prune', () => {
+ it('collapses a split whose side has gone, giving the survivor the whole box', () => {
+ expect(prune(AB, new Set(['b']))).toEqual(leaf('b'))
+ })
+
+ it('prunes deep and keeps untouched subtrees by reference', () => {
+ const deep: PaneTree = {
+ split: 'row',
+ ratio: 0.3,
+ a: leaf('a'),
+ b: { split: 'column', ratio: 0.6, a: leaf('b'), b: leaf('c') },
+ }
+ const t = prune(deep, new Set(['a', 'c']))
+ expect(t).toEqual({ split: 'row', ratio: 0.3, a: leaf('a'), b: leaf('c') })
+ expect(prune(deep, new Set(['a', 'b', 'c']))).toBe(deep)
+ })
+
+ it('is null when nothing survives', () => {
+ expect(prune(AB, new Set())).toBeNull()
+ })
+})
+
+describe('reconcile', () => {
+ it('adopts a newcomer the tree has no placement for, off the root axis', () => {
+ const t = reconcile(AB, ['a', 'b', 'c'])
+ expect(leafIds(t!)).toEqual(['a', 'b', 'c'])
+ })
+
+ it('prunes what has gone and answers the same reference when nothing changed', () => {
+ expect(reconcile(AB, ['a', 'b'])).toBe(AB)
+ expect(reconcile(AB, ['a'])).toEqual(leaf('a'))
+ })
+
+ it('builds from nothing and empties to null', () => {
+ expect(leafIds(reconcile(null, ['a', 'b'])!)).toEqual(['a', 'b'])
+ expect(reconcile(AB, [])).toBeNull()
+ })
+})
+
+describe('withRatio', () => {
+ it('sets the ratio at a path and clamps it away from the edges', () => {
+ const deep: PaneTree = {
+ split: 'row',
+ ratio: 0.5,
+ a: leaf('a'),
+ b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') },
+ }
+ const t = withRatio(deep, ['b'], 0.7)
+ expect(t).not.toBe(deep)
+ expect((t as { b: { ratio: number } }).b.ratio).toBe(0.7)
+ expect(((withRatio(deep, [], 0.01) as { ratio: number }).ratio)).toBe(0.15)
+ })
+
+ it('answers the same tree for a path that names no split', () => {
+ expect(withRatio(AB, ['a'], 0.7)).toBe(AB)
+ })
+})
+
+describe('tabs of trees', () => {
+ it('splits inside the tab that holds the target, leaving the others alone', () => {
+ const tabs = [AB, leaf('c')]
+ const next = splitInTabs(tabs, 'c', 'column', 'd')
+ expect(next[0]).toBe(AB)
+ expect(next[1]).toEqual({ split: 'column', ratio: 0.5, a: leaf('c'), b: leaf('d') })
+ })
+
+ it('declines with the same array when no tab holds the target', () => {
+ // Same reference, not just same contents: the caller holds this in React
+ // state and saves it to localStorage, so a clone would cost a render and
+ // a write for a split that placed nothing.
+ const tabs = [AB, leaf('c')]
+ expect(splitInTabs(tabs, 'missing', 'row', 'd')).toBe(tabs)
+ })
+
+ it('reconciles: prunes emptied tabs, gives an unplaced newcomer its own tab', () => {
+ const tabs = [AB, leaf('c')]
+ const next = reconcileTabs(tabs, ['a', 'b', 'x'])
+ expect(next.map(leafIds)).toEqual([['a', 'b'], ['x']])
+ // And the same reference when nothing changed — this lives in React state.
+ expect(reconcileTabs(tabs, ['a', 'b', 'c'])).toBe(tabs)
+ })
+
+ it('finds the tab holding a pane', () => {
+ expect(tabOf([AB, leaf('c')], 'b')).toBe(0)
+ expect(tabOf([AB, leaf('c')], 'c')).toBe(1)
+ expect(tabOf([AB], 'zz')).toBe(-1)
+ })
+
+ it('names the top-right pane: rightward through rows, upward through columns', () => {
+ expect(topRightLeaf(AB)).toBe('b')
+ expect(
+ topRightLeaf({
+ split: 'row',
+ ratio: 0.5,
+ a: leaf('a'),
+ b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') },
+ }),
+ ).toBe('b')
+ })
+})
+
+describe('parseTree', () => {
+ it('round-trips a stored tree and refuses garbage', () => {
+ expect(parseTree(JSON.stringify(AB))).toEqual(AB)
+ expect(parseTree(null)).toBeNull()
+ expect(parseTree('not json')).toBeNull()
+ expect(parseTree(JSON.stringify({ split: 'row', ratio: 2, a: { leaf: 'a' } }))).toBeNull()
+ expect(parseTree(JSON.stringify({ leaf: 42 }))).toBeNull()
+ })
+})
diff --git a/web/src/sessions/pane-tree.ts b/web/src/sessions/pane-tree.ts
new file mode 100644
index 0000000..dd4bfae
--- /dev/null
+++ b/web/src/sessions/pane-tree.ts
@@ -0,0 +1,272 @@
+/*
+ * The split tree: how a group's panes are arranged on a desktop.
+ *
+ * A binary tree, tmux-shaped. A leaf is a session; a split holds two
+ * subtrees along one axis and the fraction the first one takes. Splitting
+ * always targets a pane — the one whose menu row or chord asked — and
+ * replaces that leaf with a split of the old pane and the new one, so ⇧⌘D
+ * over the right column of an A|B split yields A beside a B-over-C stack,
+ * never a wholesale change of axis.
+ *
+ * Pure data and pure functions, so the arithmetic is testable without a DOM
+ * and the route can hold the tree as plain state. Every mutation returns a
+ * new tree and never touches the old one; helpers return the *same*
+ * reference when there is nothing to change, which is what lets a caller use
+ * them inside a React state updater without manufacturing re-renders.
+ */
+
+import type { SplitDirection } from '@/lib/split-keys'
+
+export type PaneTree =
+ | { leaf: string }
+ | { split: SplitDirection; ratio: number; a: PaneTree; b: PaneTree }
+
+/** A path from the root to a node: which child to take at each split. */
+export type TreePath = Array<'a' | 'b'>
+
+export function leaf(id: string): PaneTree {
+ return { leaf: id }
+}
+
+/** Every session id in the tree, left to right. */
+export function leafIds(t: PaneTree): string[] {
+ if ('leaf' in t) return [t.leaf]
+ return [...leafIds(t.a), ...leafIds(t.b)]
+}
+
+/**
+ * Replace the leaf holding `targetId` with a split of it and `newId` along
+ * `dir`, halves each. The same tree comes back when the target is not in it
+ * — the caller's placement raced a close, and inventing a position would be
+ * worse than declining.
+ */
+export function splitLeaf(
+ t: PaneTree,
+ targetId: string,
+ dir: SplitDirection,
+ newId: string,
+): PaneTree {
+ if ('leaf' in t) {
+ if (t.leaf !== targetId) return t
+ return { split: dir, ratio: 0.5, a: t, b: leaf(newId) }
+ }
+ const a = splitLeaf(t.a, targetId, dir, newId)
+ if (a !== t.a) return { ...t, a }
+ const b = splitLeaf(t.b, targetId, dir, newId)
+ if (b !== t.b) return { ...t, b }
+ return t
+}
+
+/**
+ * Drop every leaf not in `keep`, collapsing splits so the survivor takes its
+ * parent's whole box. Null when nothing survives.
+ */
+export function prune(t: PaneTree, keep: ReadonlySet): PaneTree | null {
+ if ('leaf' in t) return keep.has(t.leaf) ? t : null
+ const a = prune(t.a, keep)
+ const b = prune(t.b, keep)
+ if (a === null) return b
+ if (b === null) return a
+ if (a === t.a && b === t.b) return t
+ return { ...t, a, b }
+}
+
+/**
+ * Bring the tree in line with the panes that actually exist: prune what has
+ * gone, and hang what appeared without a recorded placement — a split made
+ * on another device, say — off the root's own axis. The same reference comes
+ * back when the tree already agrees.
+ */
+export function reconcile(t: PaneTree | null, ids: readonly string[]): PaneTree | null {
+ if (ids.length === 0) return null
+ const keep = new Set(ids)
+ let next = t === null ? null : prune(t, keep)
+ const have = next === null ? new Set() : new Set(leafIds(next))
+ for (const id of ids) {
+ if (have.has(id)) continue
+ have.add(id)
+ next =
+ next === null
+ ? leaf(id)
+ : { split: 'split' in next ? next.split : 'row', ratio: 0.5, a: next, b: leaf(id) }
+ }
+ return next
+}
+
+/**
+ * The leaf whose box touches the surface's top-right corner: rightward at a
+ * row split, upward at a column split. It is where the control strip lives —
+ * the chips belong to the surface, not to a pane, so they sit at the
+ * surface's own corner and pass to the next corner pane when that one
+ * closes.
+ */
+export function topRightLeaf(t: PaneTree): string {
+ if ('leaf' in t) return t.leaf
+ return topRightLeaf(t.split === 'row' ? t.b : t.a)
+}
+
+/**
+ * Bring a tab list in line with the panes that exist: prune every tab's
+ * tree, drop tabs that emptied, and give each unplaced newcomer a tab of its
+ * own — which is both what "new tab" resolves to before its placement lands
+ * and the honest home for a member some other device split. The same array
+ * comes back when nothing changed.
+ */
+export function reconcileTabs(tabs: readonly PaneTree[], ids: readonly string[]): PaneTree[] {
+ const keep = new Set(ids)
+ let changed = false
+ const out: PaneTree[] = []
+ for (const t of tabs) {
+ const next = prune(t, keep)
+ if (next === null) {
+ changed = true
+ continue
+ }
+ if (next !== t) changed = true
+ out.push(next)
+ }
+ const have = new Set(out.flatMap(leafIds))
+ for (const id of ids) {
+ if (have.has(id)) continue
+ have.add(id)
+ out.push(leaf(id))
+ changed = true
+ }
+ // The caller holds this in React state: an unchanged answer must be the
+ // same reference, or every reconcile pass would be a render.
+ return changed ? out : (tabs as PaneTree[])
+}
+
+/**
+ * Split `targetId`'s leaf inside whichever tab holds it. The same array when
+ * no tab does — the caller's placement raced a close.
+ */
+export function splitInTabs(
+ tabs: readonly PaneTree[],
+ targetId: string,
+ dir: SplitDirection,
+ newId: string,
+): PaneTree[] {
+ for (let i = 0; i < tabs.length; i++) {
+ const next = splitLeaf(tabs[i]!, targetId, dir, newId)
+ if (next !== tabs[i]) {
+ const out = [...tabs]
+ out[i] = next
+ return out
+ }
+ }
+ // The same reference, as promised above: this lives in React state, and a
+ // clone would spend a render (and a localStorage write) on a no-op.
+ return tabs as PaneTree[]
+}
+
+/** The index of the tab holding `id`, or -1. */
+export function tabOf(tabs: readonly PaneTree[], id: string): number {
+ return tabs.findIndex((t) => leafIds(t).includes(id))
+}
+
+/** Read a group's persisted tab list from localStorage, empty when none. */
+export function loadTabs(storageKey: string): PaneTree[] {
+ try {
+ const raw = localStorage.getItem(`${storageKey}:tabs`)
+ if (raw === null) return []
+ const parsed: unknown = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return []
+ return parsed.filter(valid)
+ } catch {
+ return []
+ }
+}
+
+/**
+ * Persist a group's tab list. Idempotent on purpose: the route calls it
+ * inside state updaters, which StrictMode double-invokes.
+ */
+export function saveTabs(storageKey: string, tabs: readonly PaneTree[]) {
+ try {
+ if (tabs.length === 0) localStorage.removeItem(`${storageKey}:tabs`)
+ else localStorage.setItem(`${storageKey}:tabs`, JSON.stringify(tabs))
+ } catch {
+ // Storage full or unavailable: the layout still works, it just will not
+ // survive a reload. Not worth surfacing.
+ }
+}
+
+/** The node at a path, or null when the path outruns the tree. */
+export function nodeAt(t: PaneTree, path: TreePath): PaneTree | null {
+ let node: PaneTree = t
+ for (const step of path) {
+ if ('leaf' in node) return null
+ node = node[step]
+ }
+ return node
+}
+
+/**
+ * The tree with the split at `path` wearing `ratio`, clamped away from the
+ * edges so no pane can be dragged to nothing. The same tree when the path
+ * names no split.
+ */
+export function withRatio(t: PaneTree, path: TreePath, ratio: number): PaneTree {
+ if (path.length === 0) {
+ const clamped = Math.min(0.85, Math.max(0.15, ratio))
+ if ('leaf' in t || t.ratio === clamped) return t
+ return { ...t, ratio: clamped }
+ }
+ if ('leaf' in t) return t
+ const step = path[0]!
+ const child = withRatio(t[step], path.slice(1), ratio)
+ if (child === t[step]) return t
+ return { ...t, [step]: child }
+}
+
+/** Read a group's persisted tree from localStorage, or null. */
+export function loadTree(storageKey: string): PaneTree | null {
+ try {
+ return parseTree(localStorage.getItem(`${storageKey}:tree`))
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Persist a group's tree. Idempotent on purpose: the route calls it inside
+ * state updaters, which StrictMode double-invokes.
+ */
+export function saveTree(storageKey: string, tree: PaneTree | null) {
+ try {
+ if (tree === null) localStorage.removeItem(`${storageKey}:tree`)
+ else localStorage.setItem(`${storageKey}:tree`, JSON.stringify(tree))
+ } catch {
+ // Storage full or unavailable: the layout still works, it just will not
+ // survive a reload. Not worth surfacing.
+ }
+}
+
+/** Parse a stored tree, refusing anything that does not hold together. */
+export function parseTree(raw: string | null): PaneTree | null {
+ if (raw === null) return null
+ try {
+ const parsed: unknown = JSON.parse(raw)
+ return valid(parsed) ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+function valid(node: unknown): node is PaneTree {
+ if (typeof node !== 'object' || node === null) return false
+ if ('leaf' in node) {
+ return typeof (node as { leaf: unknown }).leaf === 'string' && Object.keys(node).length === 1
+ }
+ const s = node as { split?: unknown; ratio?: unknown; a?: unknown; b?: unknown }
+ return (
+ (s.split === 'row' || s.split === 'column') &&
+ typeof s.ratio === 'number' &&
+ Number.isFinite(s.ratio) &&
+ s.ratio > 0 &&
+ s.ratio < 1 &&
+ valid(s.a) &&
+ valid(s.b)
+ )
+}
diff --git a/web/src/switcher/order.test.ts b/web/src/switcher/order.test.ts
index 9c6b9d4..d548c0a 100644
--- a/web/src/switcher/order.test.ts
+++ b/web/src/switcher/order.test.ts
@@ -389,3 +389,75 @@ describe('the cycle', () => {
expect(stepCycle(order, 'local/over', 1)?.id).toBe('a')
})
})
+
+describe('the group section', () => {
+ const anchor = s({ id: 'a', name: 'api' })
+ const member = s({ id: 'm1', name: 'logs', group: 'a', createdAt: '2026-01-02T00:00:00Z' })
+ const other = s({ id: 'x', name: 'elsewhere' })
+
+ it('leads the resting palette with the current group’s other terminals', () => {
+ const palette = buildPalette({
+ sessions: [other, member, anchor],
+ recents: [],
+ search: '',
+ currentKey: 'local/a',
+ })
+ expect(sectionKeys(palette)[0]).toBe('group')
+ const group = palette.sections[0]!
+ expect(group.label).toBe('This session')
+ // The session the tab is on gets no *group* row — this section is a
+ // take-me-there control, and here is not a there — though it keeps its
+ // ordinary row further down, marked current, as it always has. The
+ // sibling does not resurface below.
+ expect(group.rows.map((r) => r.key)).toEqual(['local/m1'])
+ const all = palette.sections.find((sec) => sec.key === 'all')!
+ expect(all.rows.map((r) => r.key)).toEqual(['local/a', 'local/x'])
+ })
+
+ it('resolves the group from a member’s own tab too', () => {
+ const palette = buildPalette({
+ sessions: [anchor, member],
+ recents: [],
+ search: '',
+ currentKey: 'local/m1',
+ })
+ expect(palette.sections[0]!.rows.map((r) => r.key)).toEqual(['local/a'])
+ })
+
+ it('is absent outside a session and for a group of one', () => {
+ for (const currentKey of [null, 'local/x']) {
+ const palette = buildPalette({
+ sessions: [anchor, member, other],
+ recents: [],
+ search: '',
+ currentKey,
+ })
+ expect(sectionKeys(palette)).not.toContain('group')
+ }
+ })
+
+ it('leaves a pinned sibling to the Pinned run — the number chords hang off it', () => {
+ const pinnedMember = s({ id: 'm2', name: 'pinned one', group: 'a', pinned: true })
+ const palette = buildPalette({
+ sessions: [anchor, member, pinnedMember],
+ recents: [],
+ search: '',
+ currentKey: 'local/a',
+ })
+ const group = palette.sections.find((sec) => sec.key === 'group')!
+ const pinned = palette.sections.find((sec) => sec.key === 'pinned')!
+ expect(group.rows.map((r) => r.key)).toEqual(['local/m1'])
+ expect(pinned.rows.map((r) => r.key)).toEqual(['local/m2'])
+ })
+
+ it('never crosses machines, however the ids collide', () => {
+ const farMember = s({ id: 'm1', group: 'a', machineId: 'far', machineName: 'far' })
+ const palette = buildPalette({
+ sessions: [anchor, farMember],
+ recents: [],
+ search: '',
+ currentKey: 'local/a',
+ })
+ expect(sectionKeys(palette)).not.toContain('group')
+ })
+})
diff --git a/web/src/switcher/order.ts b/web/src/switcher/order.ts
index e79634e..b261a59 100644
--- a/web/src/switcher/order.ts
+++ b/web/src/switcher/order.ts
@@ -8,6 +8,7 @@
*/
import type { FleetSession } from '@/fleet/types'
import { keyOf } from '@/fleet/types'
+import { anchorIdOf, groupMembers } from '@/sessions/groups'
import { displayName, filterSessions, orderSessions } from '@/sessions/view'
import { visitKey, type RecentVisit } from './recents'
@@ -54,7 +55,7 @@ export type SwitcherRow =
/** A headed run of rows. Sections with nothing in them are never returned. */
export interface SwitcherSection {
- key: 'pinned' | 'recent' | 'all' | 'results'
+ key: 'group' | 'pinned' | 'recent' | 'all' | 'results'
label: string
rows: SwitcherRow[]
}
@@ -125,9 +126,32 @@ function restingSections(
): SwitcherSection[] {
// The dead do not rest here — see the ended-sessions note on buildPalette.
const usable = sessions.filter((s) => s.state !== 'exited')
+
const pinned = pinnedOrder(usable)
const spoken = new Set(pinned.map(keyOf))
+ // The other terminals of the group this tab is inside — its panes and tabs
+ // — lead the palette: from within a split, "the shell next to this one" is
+ // the likeliest there of all, and the list folds members away, so this is
+ // the one place they are offered by name. The session the tab is *on* is
+ // deliberately not a row (a take-me-there control does not offer here),
+ // and a pinned sibling keeps its Pinned row instead — the ⌃⇧1..9 badges
+ // number that run, and a section that poached from it would renumber the
+ // chords.
+ const siblings: FleetSession[] = []
+ if (currentKey !== null) {
+ const here = live.get(currentKey)
+ if (here !== undefined) {
+ const machineRows = usable.filter((s) => s.machineId === here.machineId)
+ for (const s of groupMembers(machineRows, anchorIdOf(here))) {
+ const key = keyOf(s)
+ if (key === currentKey || spoken.has(key)) continue
+ spoken.add(key)
+ siblings.push(s)
+ }
+ }
+ }
+
const recentRows: SwitcherRow[] = []
for (const visit of recents) {
const key = visitKey(visit)
@@ -156,6 +180,11 @@ function restingSections(
).map((session) => liveRow(session, null, currentKey))
return trim([
+ {
+ key: 'group',
+ label: 'This session',
+ rows: siblings.map((s) => liveRow(s, null, currentKey)),
+ },
{ key: 'pinned', label: 'Pinned', rows: pinned.map((s, at) => liveRow(s, badgeAt(at), currentKey)) },
{ key: 'recent', label: 'Recent', rows: recentRows },
{ key: 'all', label: 'All sessions', rows: rest },