From b3d707083275210a01a48ee51cde4b866d49af49 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 17:01:24 +0530 Subject: [PATCH 1/6] Multiplex inside a session: split trees and tabs on desktop, tabs on mobile, and a double-Ctrl scratch terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panes are sessions, not a new server concept. The daemon gains two optional, additive fields — spawn.group links a session under an anchor, and spawn.ephemeral marks a scratch terminal — plus update.ephemeral to promote a scratch into a real member ("Keep"). A running ephemeral session lives exactly as long as its group parent: the registry's reap sweep closes it once the parent has exited, and an exited scratch is reaped in seconds rather than ten minutes. Grouped sessions snapshot and revive with their link intact; ephemeral ones are never snapshotted. The welcome announces a "multiplex" capability so the web client can feature-detect before drawing any of this; a plain session keeps exactly today's wire shape, rendering, and revival. On the web, the terminal route becomes a group host. Desktop renders tabs of split trees — ⌘D splits the focused pane to the right, ⇧⌘D stacks inside it, ⌥⌘T opens a tab beside the splits (Ctrl+Shift+D / Ctrl+Alt+Shift+D / Ctrl+Alt+T off a Mac), with draggable dividers whose drags write the DOM directly and commit once. The arrangement persists per group and device in localStorage. Mobile flattens every member to a finger-sized tab strip, one pane on screen, with the visual-viewport pinning taught about in-flow chrome above the pane. The control strip collapses to one "+" menu carrying all four verbs and their chords, worn by the surface's top-right pane only. Double-Ctrl — two bare taps, chords never count — opens the scratch terminal: an ephemeral session in the current session's directory, in a switcher-styled modal. Escape dismisses but only detaches; the shell (a dev server, a watch) keeps running and the next double-Ctrl reattaches to it, until the parent session ends. The sessions list folds members under their anchor with a pane count and hides scratches everywhere; the switcher still offers members individually. Closes #73. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + internal/daemon/conn.go | 16 +- internal/daemon/multiplex_test.go | 128 +++++++ internal/session/multiplex_test.go | 210 ++++++++++++ internal/session/registry.go | 63 +++- internal/session/session.go | 53 ++- internal/session/snapshot.go | 17 +- internal/wire/control.go | 12 + spec/protocol.md | 45 ++- testdata/wire/control.json | 63 ++++ web/src/client/client.test.ts | 109 ++++++ web/src/client/client.ts | 30 +- web/src/client/protocol.ts | 27 ++ web/src/components/session-group.test.tsx | 196 +++++++++++ web/src/components/session-group.tsx | 311 +++++++++++++++++ web/src/components/session-table.tsx | 19 ++ web/src/components/terminal.tsx | 282 ++++++++++++++-- web/src/fleet/fleet.ts | 6 + web/src/lib/split-keys.test.ts | 72 ++++ web/src/lib/split-keys.ts | 73 ++++ web/src/lib/viewport.ts | 26 +- web/src/router.tsx | 11 +- web/src/routes/sessions.tsx | 17 +- web/src/routes/terminal.tsx | 387 ++++++++++++++++++++-- web/src/scratch/context.ts | 30 ++ web/src/scratch/double-ctrl.test.ts | 115 +++++++ web/src/scratch/double-ctrl.ts | 107 ++++++ web/src/scratch/provider.tsx | 335 +++++++++++++++++++ web/src/sessions/groups.test.ts | 114 +++++++ web/src/sessions/groups.ts | 86 +++++ web/src/sessions/pane-tree.test.ts | 144 ++++++++ web/src/sessions/pane-tree.ts | 270 +++++++++++++++ 32 files changed, 3293 insertions(+), 83 deletions(-) create mode 100644 internal/daemon/multiplex_test.go create mode 100644 internal/session/multiplex_test.go create mode 100644 web/src/components/session-group.test.tsx create mode 100644 web/src/components/session-group.tsx create mode 100644 web/src/lib/split-keys.test.ts create mode 100644 web/src/lib/split-keys.ts create mode 100644 web/src/scratch/context.ts create mode 100644 web/src/scratch/double-ctrl.test.ts create mode 100644 web/src/scratch/double-ctrl.ts create mode 100644 web/src/scratch/provider.tsx create mode 100644 web/src/sessions/groups.test.ts create mode 100644 web/src/sessions/groups.ts create mode 100644 web/src/sessions/pane-tree.test.ts create mode 100644 web/src/sessions/pane-tree.ts 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..afd6c35 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,10 @@ func (r *Registry) Reap() { // a name is exactly what makes it findable in that window. DeleteMeta(dir, s.ID()) } + for _, s := range orphans { + // 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/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/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) => ( + + pane:{id} + + ), + ...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..88dcd17 --- /dev/null +++ b/web/src/components/session-group.tsx @@ -0,0 +1,311 @@ +import { useCallback, 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
{renderPane(only, 0, true)}
+ } + + if (isMobile) { + const shown = panes.some((p) => p.id === active) ? active : panes[0]!.id + return ( +
+ ({ key: p.id, label: labels.get(p.id)!, selected: p.id === shown }))} + onPick={(key) => onActivate(key)} + onNewTab={onNewTab} + /> +
+ {renderPane(shown, MOBILE_STRIP_PX, true)} +
+
+ ) + } + + // 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
{renderPane(active, 0, true)}
+ } + const strip = tabs.length > 1 + const soloLeaf = 'leaf' in tree + + const body = soloLeaf ? ( + renderPane(tree.leaf, strip ? DESKTOP_STRIP_PX : 0, true) + ) : ( + onRatio(at, path, ratio)} renderPane={renderPane} /> + ) + + if (!strip) return
{body}
+ return ( +
+ { + const ids = leafIds(t) + const label = labels.get(ids[0]!) ?? `Terminal ${i + 1}` + return { + key: ids[0]!, + label: ids.length > 1 ? `${label} · ${ids.length}` : label, + selected: i === at, + } + })} + onPick={(key) => onActivate(key)} + onNewTab={onNewTab} + /> +
{body}
+
+ ) +} + +/** + * 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 +}) { + return ( +
+ {tabs.map((t) => ( + + ))} + {onNewTab !== undefined && ( + + )} +
+ ) +} + +/** + * One tab's split tree, drawn recursively: a split is a flex box of its two + * subtrees with a draggable divider between them, a leaf is a pane. + */ +function SplitTree({ + tree, + onRatio, + renderPane, +}: { + tree: PaneTree + onRatio: (path: TreePath, ratio: number) => void + renderPane: SessionGroupProps['renderPane'] +}) { + // The two boxes either side of each divider, keyed by the divider's path, + // for the drag to write sizes into directly. A drag through state would + // re-render every mounted terminal once per pointermove — sixty times a + // second — for a style change on exactly two wrapper divs. + const boxes = useRef(new Map()) + const boxFor = (key: string) => { + let entry = boxes.current.get(key) + if (entry === undefined) { + entry = { a: null, b: null } + boxes.current.set(key, entry) + } + return entry + } + + const beginDrag = useCallback( + (path: TreePath, dir: 'row' | 'column') => (e: React.PointerEvent) => { + const holder = e.currentTarget.parentElement + if (holder === null) return + const entry = boxes.current.get(path.join('')) + if (entry === undefined || entry.a === null || entry.b === null) return + const { a, b } = entry + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + const box = holder.getBoundingClientRect() + const span = dir === 'row' ? box.width : box.height + const startAt = dir === 'row' ? e.clientX : e.clientY + const startRatio = Number(a.style.flexGrow) + let settled = startRatio + + const move = (ev: PointerEvent) => { + const at = dir === 'row' ? ev.clientX : ev.clientY + settled = Math.min(0.85, Math.max(0.15, startRatio + (at - startAt) / span)) + a.style.flexGrow = String(settled) + b.style.flexGrow = String(1 - settled) + } + const up = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + // One state commit per drag, where it also persists — React's sizes + // and the DOM's agree again from the next render on. + onRatio(path, settled) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + }, + [onRatio], + ) + + const renderNode = (node: PaneTree, path: TreePath): ReactNode => { + if ('leaf' in node) { + return ( +
{renderPane(node.leaf, 0, false)}
+ ) + } + const key = path.join('') + const entry = boxFor(key) + return ( +
+
{ + entry.a = el + }} + className="relative min-h-0 min-w-0 basis-0" + style={{ flexGrow: node.ratio }} + > + {renderNode(node.a, [...path, 'a'])} +
+
+ {/* The grab area: a 1px line is a divider, not a handle. */} + +
{ + entry.b = el + }} + className="relative min-h-0 min-w-0 basis-0" + style={{ flexGrow: 1 - node.ratio }} + > + {renderNode(node.b, [...path, 'b'])} +
+
+ ) + } + + return
{renderNode(tree, [])}
+} diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index 2ee872a..8ab82cd 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,14 @@ 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. + + {paneCount} panes + + )} {s.cmd.join(' ')} @@ -387,6 +398,7 @@ function PinnedRule() { */ export function SessionTable({ groups, + panes, columns, selected, onToggleSelect, @@ -398,6 +410,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 +551,7 @@ export function SessionTable({ {at === boundary && at > 0 && } 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 + /** + * The group a Restart should spawn back into. A member pane that restarts + * without this would come back as a stranger outside its own group; the + * route knows the anchor, the terminal just carries it to the spawn. + */ + restartGroup?: string + /** + * Whether a Restart spawns another ephemeral session. True only inside the + * scratch modal, whose restarted shell must stay a scratch — bound to the + * same parent, hidden from the same lists — rather than climb out of the + * modal as a real session. + */ + restartEphemeral?: boolean + /** + * 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. */ @@ -154,9 +218,17 @@ export function Terminal({ onRestarted, onClosed, onNewSession, + onSplit, + restartGroup, + restartEphemeral = false, + 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()), []) @@ -234,6 +306,16 @@ export function Terminal({ // a prop identity in its dependency array. const restartedRef = useRef(onRestarted) restartedRef.current = onRestarted + // Same treatment for the group a Restart spawns back into, and whether it + // stays a scratch. + const restartGroupRef = useRef(restartGroup) + restartGroupRef.current = restartGroup + const restartEphemeralRef = useRef(restartEphemeral) + restartEphemeralRef.current = restartEphemeral + // And 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 @@ -365,6 +447,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 +821,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. @@ -905,6 +992,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. @@ -942,7 +1041,13 @@ export function Terminal({ // 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 }) + const reqId = client.spawn({ + cwd: dir ?? undefined, + cols: dims.cols, + rows: dims.rows, + group: restartGroupRef.current, + ephemeral: restartEphemeralRef.current || undefined, + }) if (reqId !== null) restartReq = reqId }, applyTheme: (id) => { @@ -1048,7 +1153,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,7 +1164,10 @@ 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?.() @@ -1083,6 +1193,10 @@ export function Terminal({
- + {chrome === 'full' && } + {chrome === 'full' && ( + <> {/* The way to another session without leaving this one. @@ -1194,29 +1310,37 @@ 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} + /> + )} + + )} {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 @@ -1284,3 +1408,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 ( + + + + + + + + + {onSplit !== undefined && ( + <> + onSplit('row')}> + + onSplit('column')}> + + onSplit('tabs')}> + + + )} + {onScratch !== undefined && ( + + + )} + + + + ) +} 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..e901b62 --- /dev/null +++ b/web/src/lib/split-keys.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { matchSplitChord, splitChordLabel } 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('splitChordLabel', () => { + it('prints 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') + }) +}) diff --git a/web/src/lib/split-keys.ts b/web/src/lib/split-keys.ts new file mode 100644 index 0000000..bae1a93 --- /dev/null +++ b/web/src/lib/split-keys.ts @@ -0,0 +1,73 @@ +/* + * 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' +} diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts index e5cce40..a68c09d 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,22 +77,30 @@ 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 handlers this tracker is replacing, put back on dispose. The slots + // are single-occupancy by design (see ViewportLike on why they are + // properties), and two panes legitimately overlap now: the scratch modal's + // terminal mounts over the route's, and a dispose that nulled the slot + // would leave the surviving pane deaf to the keyboard for the rest of its + // life. Restoring what was found keeps the trackers a stack. + const prevResize = viewport.onresize + const prevScroll = viewport.onscroll viewport.onresize = apply viewport.onscroll = apply apply() return () => { - // Each slot is surrendered only if it is still this tracker's. Nulling + // Each slot is surrendered only if it is still this tracker's. Restoring // 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 + if (viewport.onresize === apply) viewport.onresize = prevResize + if (viewport.onscroll === apply) viewport.onscroll = prevScroll surface.style.touchAction = '' gestureArea.style.touchAction = '' pane.style.height = '' 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]) + + // 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 + 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. + { + setDismissed((prev) => new Set(prev).add(id)) + client.list() + if (id === sessionId) { + // A member's replacement is in this same group; seed the + // held anchor so the surface stays put across the + // navigation instead of collapsing until the list lands. An + // anchor's replacement is a fresh standalone session, which + // is exactly what the unseeded fallback resolves. + if (id !== anchorId) anchorRef.current = { resolvedFor: newId, anchor: anchorId } + goTo(newId) + } else setActive(newId) + }} + onClosed={() => { + const remaining = paneIds.filter((p) => p !== id) + if (remaining.length === 0) { + 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.tsx b/web/src/scratch/provider.tsx new file mode 100644 index 0000000..f8110dd --- /dev/null +++ b/web/src/scratch/provider.tsx @@ -0,0 +1,335 @@ +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) + 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 + const adopt = (rows: SessionInfo[]) => { + const existing = rows.find( + (s) => s.group === anchor && s.ephemeral === true && s.state === 'running', + ) + if (existing !== undefined) { + settle() + setOpen({ machineId, sessionId: existing.id, parentId: anchor }) + return + } + const cwd = rows.find((s) => s.id === anchor)?.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) + 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. + */} +
+
+
+ {open !== null && client !== null && ( + + + setOpen({ machineId: open.machineId, sessionId: id, parentId: open.parentId }) + } + onClosed={dismiss} + /> + + )} +
+
+
+
+
+ ) +} 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..7655a3d --- /dev/null +++ b/web/src/sessions/pane-tree.test.ts @@ -0,0 +1,144 @@ +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('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..a2e7ad1 --- /dev/null +++ b/web/src/sessions/pane-tree.ts @@ -0,0 +1,270 @@ +/* + * 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 + } + } + return [...tabs] +} + +/** 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 { + const clamped = Math.min(0.85, Math.max(0.15, ratio)) + if (path.length === 0) { + 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), clamped) + 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) + ) +} From f732e9b4cf0b82e20574dca3516b124887235e1a Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 17:15:44 +0530 Subject: [PATCH 2/6] Group polish: theme sweeps every pane, exits just close, tab chords, and the switcher knows the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme choice now reaches every terminal in the document, not only the pane whose menu took the click: the storage event only ever fires in other tabs, which was sufficient exactly as long as one terminal per document held — the preference module gains a same-document announcement and every terminal subscribes. The exit overlay is gone. A shell that ends hands the exit to the view above and that view closes it: a split pane folds away, the scratch modal dismisses (typing exit in a scratch now puts it away), a lone session leaves for the dashboard. The one case that stays on screen is a session opened after it had already exited — reading those is what the daemon's retention window is for. ⌥⌘←/→ (Ctrl+Alt+←/→) walk the tabs of the group on screen — trees on a desktop, panes on a phone — in the same modifier family as ⌥⌘T, since every conventional spelling belongs to the browser. The ⌘K switcher leads with a "This session" section naming the group's other terminals, which is the one place they are offered by name now that the sessions list folds them away; pinned siblings keep their Pinned rows so the ⌃⇧1..9 badges never renumber. The list's fold badge says "N terminals" — panes and tabs are per-device renderings of the same group, and the badge should not pick a side. Co-Authored-By: Claude Fable 5 --- web/src/components/exit-overlay.tsx | 46 ------------ web/src/components/session-table.tsx | 5 +- web/src/components/terminal.test.tsx | 73 +++++------------- web/src/components/terminal.tsx | 107 +++++++++------------------ web/src/lib/split-keys.test.ts | 62 +++++++++++++++- web/src/lib/split-keys.ts | 19 +++++ web/src/lib/theme-pref.test.ts | 26 ++++++- web/src/lib/theme-pref.ts | 31 +++++++- web/src/routes/terminal.tsx | 79 +++++++++++++------- web/src/scratch/provider.tsx | 8 +- web/src/switcher/order.test.ts | 72 ++++++++++++++++++ web/src/switcher/order.ts | 31 +++++++- 12 files changed, 347 insertions(+), 212 deletions(-) delete mode 100644 web/src/components/exit-overlay.tsx 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-table.tsx b/web/src/components/session-table.tsx index 8ab82cd..c1f8438 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -206,9 +206,10 @@ function SessionRow({ {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. + // is N-1 terminals nobody can account for. "Terminals", not panes + // or tabs: those are per-device renderings of the same group. - {paneCount} panes + {paneCount} terminals )} 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 f6ee218..9f4eb69 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -11,7 +11,6 @@ import { } 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' @@ -20,7 +19,7 @@ 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, @@ -59,11 +58,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 @@ -85,19 +86,6 @@ export interface TerminalProps { * verbs. */ onSplit?: (cwd: string | null, layout: GroupLayout) => void - /** - * The group a Restart should spawn back into. A member pane that restarts - * without this would come back as a stranger outside its own group; the - * route knows the anchor, the terminal just carries it to the spawn. - */ - restartGroup?: string - /** - * Whether a Restart spawns another ephemeral session. True only inside the - * scratch modal, whose restarted shell must stay a scratch — bound to the - * same parent, hidden from the same lists — rather than climb out of the - * modal as a real session. - */ - restartEphemeral?: boolean /** * 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 @@ -215,12 +203,9 @@ const PRESS_SLOP = 10 export function Terminal({ sessionId, createEmulator = createXtermEmulator, - onRestarted, onClosed, onNewSession, onSplit, - restartGroup, - restartEphemeral = false, chrome = 'full', fitViewport = true, viewportInset = 0, @@ -259,7 +244,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. // @@ -294,7 +278,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 @@ -302,18 +285,12 @@ 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 - // Same treatment for the group a Restart spawns back into, and whether it - // stays a scratch. - const restartGroupRef = useRef(restartGroup) - restartGroupRef.current = restartGroup - const restartEphemeralRef = useRef(restartEphemeral) - restartEphemeralRef.current = restartEphemeral - // And for tab-title ownership, which flips without a remount when the URL - // moves between two panes of one group. + // 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 @@ -419,9 +396,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 @@ -870,16 +844,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 @@ -945,8 +909,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?.() }), ) @@ -1036,20 +1006,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, - group: restartGroupRef.current, - ephemeral: restartEphemeralRef.current || undefined, - }) - if (reqId !== null) restartReq = reqId - }, applyTheme: (id) => { const next = resolveTheme(id, prefersDark()) emulator.setTheme(next) @@ -1113,9 +1069,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 @@ -1123,6 +1078,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. @@ -1141,6 +1105,7 @@ export function Terminal({ glide?.() untrackViewport() window.removeEventListener('storage', onStorage) + offThemePref() window.removeEventListener('keydown', onKey, true) observer.disconnect() media?.removeEventListener?.('change', onScheme) @@ -1169,12 +1134,11 @@ export function Terminal({ // 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, @@ -1394,9 +1358,6 @@ export function Terminal({
)}
- {phase === 'exited' && exitCode !== null && ( - - )} ) } diff --git a/web/src/lib/split-keys.test.ts b/web/src/lib/split-keys.test.ts index e901b62..7920565 100644 --- a/web/src/lib/split-keys.test.ts +++ b/web/src/lib/split-keys.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { matchSplitChord, splitChordLabel } from './split-keys' +import { + matchNewTabChord, + matchSplitChord, + matchTabCycleChord, + newTabChordLabel, + splitChordLabel, + tabCycleChordLabel, +} from './split-keys' function key(over: Partial): KeyboardEvent { return { @@ -62,11 +69,60 @@ describe('matchSplitChord', () => { }) }) -describe('splitChordLabel', () => { - it('prints the platform spelling', () => { +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 index bae1a93..2c3af2f 100644 --- a/web/src/lib/split-keys.ts +++ b/web/src/lib/split-keys.ts @@ -71,3 +71,22 @@ export function matchNewTabChord(e: KeyboardEvent, apple: boolean): boolean { 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/routes/terminal.tsx b/web/src/routes/terminal.tsx index 3c8ae6c..e956a83 100644 --- a/web/src/routes/terminal.tsx +++ b/web/src/routes/terminal.tsx @@ -8,10 +8,16 @@ import { SessionGroup } from '@/components/session-group' import { Terminal } from '@/components/terminal' import { useFleet } from '@/fleet/provider' import type { FleetSession, MachineState } from '@/fleet/types' -import { matchNewTabChord, matchSplitChord, type GroupLayout } from '@/lib/split-keys' +import { + matchNewTabChord, + matchSplitChord, + matchTabCycleChord, + type GroupLayout, +} from '@/lib/split-keys' import { anchorIdOf, groupMembers } from '@/sessions/groups' import { useIsMobile } from '@/hooks/use-mobile' import { + leafIds, loadTabs, reconcileTabs, saveTabs, @@ -89,13 +95,12 @@ export function TerminalRoute() { // spawn a stranger next to the session instead of a pane in it. const canMultiplex = useHasCap(client, 'multiplex') - // Panes this view has been asked to stop showing: a member whose exit - // overlay was closed, or one replaced by its own Restart. The row lingers - // in the daemon's list for the exited retention, so "closed" has to be - // this view's own fact. Never reset — session ids do not recur, a stale - // entry for a reaped session matches nothing, and a reset keyed on the - // anchor was measured resurrecting dismissed panes when a Restart's - // navigation made the anchor flap for a beat. + // Panes whose shells exited while this view watched, folded away. The row + // lingers in the daemon's list for the exited retention, so "closed" has + // to be this view's own fact. Never reset — session ids do not recur, a + // stale entry for a reaped session matches nothing, and a reset keyed on + // the anchor was measured resurrecting dismissed panes when navigation + // made the anchor flap for a beat. const [dismissed, setDismissed] = useState>(() => new Set()) // The URL keeps naming the session that was opened; the group is resolved @@ -300,12 +305,47 @@ export function TerminalRoute() { 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) @@ -342,26 +382,15 @@ export function TerminalRoute() { // splits whichever pane holds the keyboard, so the chips' // placement costs a sibling nothing but the pointer route. chrome={id === chipsPane ? 'full' : 'minimal'} - restartGroup={id === anchorId ? undefined : anchorId} - // replace, both ways: the dead session's URL is not worth a Back - // stop. A member pane restarting stays where it is — the new - // member takes the pane over through the refreshed list. - onRestarted={(newId) => { - setDismissed((prev) => new Set(prev).add(id)) - client.list() - if (id === sessionId) { - // A member's replacement is in this same group; seed the - // held anchor so the surface stays put across the - // navigation instead of collapsing until the list lands. An - // anchor's replacement is a fresh standalone session, which - // is exactly what the unseeded fallback resolves. - if (id !== anchorId) anchorRef.current = { resolvedFor: newId, anchor: anchorId } - goTo(newId) - } else setActive(newId) - }} onClosed={() => { + // 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) if (remaining.length === 0) { + // replace: a dead session's URL is not worth a Back stop. void navigate({ to: '/', replace: true }) return } diff --git a/web/src/scratch/provider.tsx b/web/src/scratch/provider.tsx index f8110dd..1b4c3b5 100644 --- a/web/src/scratch/provider.tsx +++ b/web/src/scratch/provider.tsx @@ -315,13 +315,11 @@ export function ScratchProvider({ children }: { children: ReactNode }) { sessionId={open.sessionId} chrome="minimal" ownsTitle={false} - restartGroup={open.parentId} - restartEphemeral fitViewport={isMobile} viewportInset={isMobile ? HEADER_PX : 0} - onRestarted={(id) => - setOpen({ machineId: open.machineId, sessionId: id, parentId: open.parentId }) - } + // The exit is the close now: typing `exit` in a scratch + // puts the modal away, and the fast ephemeral reap does + // the rest. onClosed={dismiss} /> 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 }, From f4cb71a306816780f63b2eb1e25a38b4487a314e Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 17:29:06 +0530 Subject: [PATCH 3/6] =?UTF-8?q?A=20keyboard-shortcuts=20card=20in=20the=20?= =?UTF-8?q?control=20strip,=20on=20=E2=8C=98/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chord in one place — switcher, splits, tabs, scratch, focus mode — spelled for the keyboard at hand, because the pills that used to teach them are gone in a hundred milliseconds on a local daemon and each tooltip teaches one chord. ⌘/ is the palette-help spelling everywhere else; off a Mac it is Ctrl+Shift+/, in the reserved Ctrl+Shift namespace, since plain Ctrl+/ is readline's undo and may not be taken. Co-Authored-By: Claude Fable 5 --- web/src/components/shortcuts-help.test.tsx | 73 +++++++++ web/src/components/shortcuts-help.tsx | 173 +++++++++++++++++++++ web/src/components/terminal.tsx | 7 + 3 files changed, 253 insertions(+) create mode 100644 web/src/components/shortcuts-help.test.tsx create mode 100644 web/src/components/shortcuts-help.tsx diff --git a/web/src/components/shortcuts-help.test.tsx b/web/src/components/shortcuts-help.test.tsx new file mode 100644 index 0000000..904a6a4 --- /dev/null +++ b/web/src/components/shortcuts-help.test.tsx @@ -0,0 +1,73 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' + +import { helpChordLabel, matchHelpChord, ShortcutsHelp } from './shortcuts-help' + +function key(over: Partial): 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('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..886ecec --- /dev/null +++ b/web/src/components/shortcuts-help.tsx @@ -0,0 +1,173 @@ +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 }: { chipStyle: CSSProperties }) { + 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 ( + + + + + + + +
+
+
+ {sections(apple).map((section) => ( +
+

+ {section.label} +

+
+ {section.rows.map((row) => ( +
+
+ {row.what} +
+
+ {row.keys} +
+
+ ))} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 9f4eb69..73311d9 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -14,6 +14,7 @@ import { useFlueClient } from '@/client/provider' 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' @@ -1303,6 +1304,12 @@ export function Terminal({ 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. + */} + )} {phase !== 'live' && ( From ad65353cdc295777729a46debfe8b69528447b6a Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 17:34:13 +0530 Subject: [PATCH 4/6] No shortcuts chip for a finger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card of keyboard chords is furniture on a phone, so a coarse pointer gets no chip. The ⌘/ listener stays mounted either way: an iPad grows a hardware keyboard without changing its pointer, and the chord should answer it. Co-Authored-By: Claude Fable 5 --- web/src/components/shortcuts-help.test.tsx | 11 ++++++ web/src/components/shortcuts-help.tsx | 46 ++++++++++++++-------- web/src/components/terminal.tsx | 6 ++- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/web/src/components/shortcuts-help.test.tsx b/web/src/components/shortcuts-help.test.tsx index 904a6a4..21b2192 100644 --- a/web/src/components/shortcuts-help.test.tsx +++ b/web/src/components/shortcuts-help.test.tsx @@ -56,6 +56,17 @@ describe('ShortcutsHelp', () => { 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+/. diff --git a/web/src/components/shortcuts-help.tsx b/web/src/components/shortcuts-help.tsx index 886ecec..9a88f8e 100644 --- a/web/src/components/shortcuts-help.tsx +++ b/web/src/components/shortcuts-help.tsx @@ -88,7 +88,19 @@ function sections(apple: boolean): Section[] { * route mounts the strip on one pane, so the chord listener here is single * too. */ -export function ShortcutsHelp({ chipStyle }: { chipStyle: CSSProperties }) { +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) @@ -107,21 +119,23 @@ export function ShortcutsHelp({ chipStyle }: { chipStyle: CSSProperties }) { return ( - - - + {chip && ( + + + + )} + )} {phase !== 'live' && ( From 52191e0dca3934b4c0012f204b7e2bfb9cc1b899 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 17:42:41 +0530 Subject: [PATCH 5/6] The tab strip scrolls instead of squeezing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs were shrinkable flex children, so too many of them absorbed the overflow by squishing each other into slivers — a strip that never overflowed had nothing to pan. shrink-0 keeps every tab its natural width and lets the overflow engage, and the selected tab now keeps itself in view, so the cycle chord landing on a scrolled-past tab reads as a move rather than as nothing. Co-Authored-By: Claude Fable 5 --- web/src/components/session-group.tsx | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/web/src/components/session-group.tsx b/web/src/components/session-group.tsx index 88dcd17..3a77d87 100644 --- a/web/src/components/session-group.tsx +++ b/web/src/components/session-group.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, type ReactNode } from 'react' import { PlusIcon } from 'lucide-react' import { useIsMobile } from '@/hooks/use-mobile' @@ -149,12 +149,30 @@ function Strip({ 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 (
@@ -164,9 +182,15 @@ function Strip({ type="button" role="tab" aria-selected={t.selected} + ref={(el) => { + if (t.selected) selectedEl.current = el + }} onClick={() => onPick(t.key)} + // shrink-0 is what makes the strip scroll at all: shrinkable flex + // children absorb the overflow by squeezing each other into + // slivers, and a strip that never overflows has nothing to pan. className={cn( - 'max-w-48 truncate rounded-md whitespace-nowrap', + 'max-w-48 shrink-0 truncate rounded-md whitespace-nowrap', height === 'mobile' ? 'px-3 py-1.5 text-base/5' : 'px-2.5 py-1 text-sm/5', t.selected ? 'bg-zinc-950/5 text-zinc-950 dark:bg-white/10 dark:text-white' From 398f0c6c5a955347a531fccdf76f58d90dcaab19 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 19:31:25 +0530 Subject: [PATCH 6/6] Review fixes: viewport tracker stack, scratch guards, relay goroutine join Fixes from a second review pass over the multiplex branch, plus the CI flake that predates it. - viewport.ts kept a captured-previous chain per tracker, which breaks the moment trackers dispose out of install order: the last disposer restores a dead tracker's handler, leaving a stale closure pinning a detached pane while the live one goes deaf to the keyboard. The stack is now explicit and shared per viewport; disposal removes exactly its own entry and the slot always belongs to the newest survivor. - The scratch resolution now checks the route at every landing: an answer that arrives after navigating away no longer pops the modal over a different session, and a chord whose parent is not a running session declines to spawn (the daemon's sweep would close the newborn within seconds, in the wrong directory no less). - The split chord's focus ref is cleared when the pane it names closes, so a chord in the gap before the next focusin cannot aim at the dead pane. - Registry.Reap re-reads the ephemeral flag before closing an orphan: a Keep landing between collection and the close no longer loses to the sweep. - splitInTabs returns the same array when it declines, as its contract already promised, and withRatio clamps once at the target split. - The relay transport joins its serve and pairing goroutines before Run returns. Those goroutines write into the device registry (last-seen stamps, pairing entries), and a Run that returned while one was still going left the write racing the caller's teardown - in CI, the test harness deleting the registry directory out from under it, which is the TempDir flake that failed the previous run. Co-Authored-By: Claude Fable 5 --- internal/session/registry.go | 9 ++ internal/transport/relay/channel.go | 8 +- internal/transport/relay/channel_test.go | 100 ++++++++++++++- internal/transport/relay/relay.go | 13 ++ web/src/lib/viewport.test.ts | 52 ++++++++ web/src/lib/viewport.ts | 80 +++++++++--- web/src/routes/terminal.tsx | 5 + web/src/scratch/provider.test.tsx | 151 +++++++++++++++++++++++ web/src/scratch/provider.tsx | 24 +++- web/src/sessions/pane-tree.test.ts | 8 ++ web/src/sessions/pane-tree.ts | 8 +- 11 files changed, 436 insertions(+), 22 deletions(-) create mode 100644 web/src/scratch/provider.test.tsx diff --git a/internal/session/registry.go b/internal/session/registry.go index afd6c35..118ba97 100644 --- a/internal/session/registry.go +++ b/internal/session/registry.go @@ -460,6 +460,15 @@ func (r *Registry) Reap() { 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. 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/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 a68c09d..6fd961e 100644 --- a/web/src/lib/viewport.ts +++ b/web/src/lib/viewport.ts @@ -81,29 +81,79 @@ export function trackVisualViewport(opts: { pane.style.translate = `0px ${viewport.offsetTop}px` } - // The handlers this tracker is replacing, put back on dispose. The slots - // are single-occupancy by design (see ViewportLike on why they are - // properties), and two panes legitimately overlap now: the scratch modal's - // terminal mounts over the route's, and a dispose that nulled the slot - // would leave the surviving pane deaf to the keyboard for the rest of its - // life. Restoring what was found keeps the trackers a stack. - const prevResize = viewport.onresize - const prevScroll = viewport.onscroll + // 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. Restoring - // 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 = prevResize - if (viewport.onscroll === apply) viewport.onscroll = prevScroll 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/routes/terminal.tsx b/web/src/routes/terminal.tsx index e956a83..e4300e1 100644 --- a/web/src/routes/terminal.tsx +++ b/web/src/routes/terminal.tsx @@ -389,6 +389,11 @@ export function TerminalRoute() { // 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 }) 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 index 1b4c3b5..2579143 100644 --- a/web/src/scratch/provider.tsx +++ b/web/src/scratch/provider.tsx @@ -100,6 +100,11 @@ export function ScratchProvider({ children }: { children: ReactNode }) { // 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 @@ -129,16 +134,29 @@ export function ScratchProvider({ children }: { children: ReactNode }) { } 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 cwd = rows.find((s) => s.id === anchor)?.cwd + 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, @@ -157,6 +175,10 @@ export function ScratchProvider({ children }: { children: ReactNode }) { // 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) => { diff --git a/web/src/sessions/pane-tree.test.ts b/web/src/sessions/pane-tree.test.ts index 7655a3d..71bc214 100644 --- a/web/src/sessions/pane-tree.test.ts +++ b/web/src/sessions/pane-tree.test.ts @@ -106,6 +106,14 @@ describe('tabs of trees', () => { 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']) diff --git a/web/src/sessions/pane-tree.ts b/web/src/sessions/pane-tree.ts index a2e7ad1..dd4bfae 100644 --- a/web/src/sessions/pane-tree.ts +++ b/web/src/sessions/pane-tree.ts @@ -155,7 +155,9 @@ export function splitInTabs( return out } } - return [...tabs] + // 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. */ @@ -206,14 +208,14 @@ export function nodeAt(t: PaneTree, path: TreePath): PaneTree | null { * names no split. */ export function withRatio(t: PaneTree, path: TreePath, ratio: number): PaneTree { - const clamped = Math.min(0.85, Math.max(0.15, ratio)) 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), clamped) + const child = withRatio(t[step], path.slice(1), ratio) if (child === t[step]) return t return { ...t, [step]: child } }