Files
muxplex/docs/plans/2026-04-15-views-design.md
T
Brian Krabach 93889f7dda docs: add Views feature design
Adds design specification for user-defined Views — curated session
collections that span devices and replace the filtered gridViewMode.

Covers:
- Stable device identity prerequisite (device_id UUID in identity.json)
- Data model: views array in settings, active_view in state
- Three view tiers: All (virtual), user-created, Hidden (virtual)
- Mutual exclusion invariant between hidden and view membership
- UI: header dropdown view switcher, tile flyout menu, add sessions panel
- Migration strategy for session key format change
- Known limitations: rename breakage, atomic sync, shortcut cap
2026-04-15 09:38:29 -07:00

20 KiB
Raw Blame History

Views Design

Goal

Add user-defined "Views" to muxplex — curated collections of sessions that let users focus on subsets of their terminal sessions across devices, replacing the current filtered gridViewMode with something strictly more powerful.

Background

Muxplex currently organizes sessions by device name (grouped mode), a flat sorted grid (flat mode), or a per-device filter pill bar (filtered mode). There is no way to create a custom collection of sessions that spans devices. Users working on projects that involve sessions across multiple machines (a hobby project on a desktop and a Pi, a work project on a laptop and a server) have no way to create a focused view of just those sessions.

The existing hidden_sessions feature is a flat list of session names buried in the Settings panel. Hiding by name means hiding "main" hides it on all devices, and there is no way to see what you've hidden without opening Settings.

Views solve both problems: user-curated session sets for focused work, and a first-class "Hidden" destination that replaces the buried settings checkbox.

Prerequisite: Stable Device Identity

The current remoteId is a positional array index from enumerate(remote_instances) — it shifts when the list is reordered. This is a pre-existing fragility that views would make worse, since view session lists need stable keys that work identically regardless of which device is running the web session. This prerequisite fixes device identity system-wide.

Identity File

Each muxplex instance gets a device_id (UUID v4) stored in a new file:

~/.config/muxplex/identity.json

{
  "device_id": "550e8400-e29b-41d4-a716-446655440000"
}
  • Stored outside the federation settings sync boundary entirely. Never synced, never overwritten by settings propagation.
  • Generated once on first startup via uuid.uuid4() if the file is absent.
  • Never regenerated automatically. Not user-editable through the UI.

Session Key Format

All session keys change from "remoteId:name" (positional index) to "device_id:name" uniformly — for both local AND remote sessions. No asymmetry. The local device knows its own device_id at startup, so local sessions use "local_device_id:session_name" just like remotes.

Session Type Old Format New Format
Local "name" "device_id:name"
Remote "remoteId:name" (e.g. "1:dev-server") "device_id:name" (e.g. "abc-123:dev-server")

Remote Instance Changes

  • remote_instances entries gain a device_id field, populated by querying the remote's /api/instance-info endpoint.
  • /api/instance-info response extended to include device_id:
{
  "name": "my-laptop",
  "device_id": "a1b2c3d4-...",
  "version": "0.3.7",
  "federation_enabled": true
}
  • active_remote_id in state.json changes from integer index to device_id string.
  • All frontend session key construction/parsing (sessionKey, data-remote-id, API URL construction, bell tracking, etc.) updated to use device_id instead of positional index.
  • All backend federation proxy endpoints updated to accept device_id-based lookup instead of integer index.

Device-ID Change Detection

If a remote's /api/instance-info returns a different device_id than what's stored in remote_instances, log a warning. Do NOT silently update — require operator acknowledgment before accepting the change.

CLI Addition

muxplex --reset-device-id — generates a new UUID, warns about orphaned session keys in views and hidden_sessions. For the "I copied my dotfiles to a new machine" scenario.

Config Path Alignment

Migrate state storage from ~/.local/share/tmux-web/ to ~/.local/share/muxplex/ (legacy tmux-web name). Handle both paths during transition: check new path first, fall back to old path, migrate on first write.

Migration

Migration is incremental and per-remote, NOT blocking:

  1. On startup, check each remote instance entry: if it lacks a device_id, query its /api/instance-info.
  2. If the remote is reachable, store its device_id and rewrite any session keys in state/settings that used the old positional index.
  3. If unreachable, skip that remote. Retry on next startup.
  4. Track migration status per-remote so a crash mid-migration doesn't re-run already-migrated remotes.
  5. Support both old "remoteId:name" and new "device_id:name" key formats during the transition window.

Data Model

Settings (~/.config/muxplex/settings.json)

New and changed fields:

{
  "views": [
    { "name": "Work Project", "sessions": ["abc-123:dev-server", "def-456:monitoring"] },
    { "name": "Hobby", "sessions": ["abc-123:3d-printer", "abc-123:dev-server"] }
  ],
  "hidden_sessions": ["abc-123:old-experiment", "def-456:stale-build"]
}
  • views is an ordered list of {name: string, sessions: string[]} objects. Array order = dropdown order.
  • Session keys use device_id:name format uniformly.
  • views and hidden_sessions are in SYNCABLE_KEYS (synced across federation).
  • The old gridViewMode setting's "filtered" value is removed. Only "flat" and "grouped" remain.

State (~/.local/share/muxplex/state.json)

New field:

{
  "active_view": "all"
}
  • active_view is "all" (default), "hidden", or a view name string.
  • Stored in state.json, NOT settings.json — per-device, NOT synced via federation. Prevents switching views on one device from changing the view on another device.
  • If active_view references a view name that no longer exists (deleted on another device via federation sync), fall back to "all".

View Name Validation

  • Non-empty, max 30 characters, whitespace-trimmed, unique among user views.
  • Names "all" and "hidden" are reserved (case-insensitive).

Architecture

Three View Tiers

Tier Name Stored? Editable? Behavior
System "All" No — virtual No Shows every session NOT in hidden_sessions
User e.g. "Work", "Hobby" Yes — views[] in settings Full CRUD User-curated session sets
System "Hidden" No — virtual Sessions can be unhidden from here Shows everything in hidden_sessions

"All" and "Hidden" are never stored in the views array. "All" is computed as "everything not in hidden_sessions". "Hidden" is computed as "everything in hidden_sessions".

Session Lifecycle Within Views

  • New sessions auto-appear in "All" — they're not hidden, so they're visible. No action needed.
  • New sessions created via the + button while a user view is active — auto-added to that view AND visible in "All". Only applies to sessions created through the muxplex UI (the + button), NOT sessions that appear on federated remotes during a poll cycle.
  • Hiding a session — removes it from ALL user views, adds to hidden_sessions. It now only appears in the "Hidden" view.
  • Unhiding a session (from Hidden view) — removes from hidden_sessions, reappears in "All". Does NOT auto-restore to previous user views.
  • Adding a hidden session to a user view (from the Add Sessions panel) — removes from hidden_sessions, adds to the view. Now visible in "All" and that view.
  • Deleting a view — if the deleted view is the active view, fall back to "All". Sessions that were in the deleted view remain wherever else they are (in "All" and any other views). They don't become orphaned.
  • Killing a session (via flyout) — removes it from all views and hidden_sessions automatically (the session no longer exists).

Mutual Exclusion Invariant

hidden_sessions and any views[].sessions never share a session key. Every write path enforces this:

  • Hiding removes from all views.
  • Adding a hidden session to a view removes from hidden.
  • An invariant-repair function runs after every federation settings sync. If a session key appears in both hidden_sessions and any view (possible due to concurrent writes on different devices), remove it from hidden_sessions (favor visibility over hiding).

Stale Session Keys

When a view's session list contains a key for a session that no longer exists (tmux session died, remote unreachable), the session is silently omitted from rendering. The key stays in the view's session list so it reappears if the session comes back (e.g., tmux session recreated with the same name on the same device).

Layout Mode Changes

  • gridViewMode retains only "flat" and "grouped" values. The "filtered" value is removed entirely.
  • flat/grouped is a global toggle that applies within whatever view is active. "Grouped" groups sessions by device within the current view's filtered session set.
  • The current device filter pill bar (rendered when gridViewMode === "filtered") is removed from the UI.

UI: Header Dropdown (View Switcher)

Location

In the header bar, between the wordmark and the action buttons. Shows the current view name as a clickable label (e.g., "All Sessions ▾").

Dropdown Structure

┌─────────────────────────┐
│  ✓ All Sessions         │  ← always first, checkmark on active
│─────────────────────────│
│    Work Project          │  ← user views, in array order
│    Hobby                 │
│    Monitoring            │
│─────────────────────────│
│    Hidden (3)            │  ← always last, shows count of hidden sessions
│─────────────────────────│
│  + New View              │  ← inline text input on click
│  Manage Views...         │  ← opens settings tab
└─────────────────────────┘

"Hidden" shows the count of hidden_sessions in parentheses.

Keyboard

  • Backtick (`) opens the dropdown — ONLY on the grid overview page, NOT in fullscreen terminal mode (backtick is a real character users type in shells).
  • Arrow keys navigate, Enter selects, Escape closes.
  • Number keys 1-9 jump directly: 1=All, 28=up to 7 user views, 9=Hidden. Views beyond position 7 don't have shortcuts. No hard cap on view count; shortcuts just stop at 9.

"New View" Flow

Click + New View → inline text input appears in the dropdown. Type name, press Enter → empty view created, dropdown closes, switches to the new view.

"Manage Views..."

Opens a new "Views" tab in the existing settings dialog:

  • List of user views with inline rename (click name to edit).
  • Up/down arrow buttons for reorder (no drag-to-reorder — ship with arrow buttons, add drag later if users ask).
  • Delete with inline confirmation: clicking delete replaces the button with "Sure? [Yes] [No]" in place — no nested modal.

Fullscreen Sidebar

The sidebar header shows the current view name with a small that opens the same dropdown. Implemented as two independent DOM instances with a shared render function — not DOM teleportation. Allows view switching without leaving fullscreen.

Mobile

Header label truncated if needed. Tapping opens a bottom sheet (consistent with existing mobile patterns) instead of a floating dropdown.

Deleted Active View Fallback

If active_view references a view name that no longer exists (deleted on another device, or deleted via Manage Views), fall back to "All". Sessions that were in the deleted view remain in "All" and any other views they belong to.

UI: Tile Flyout Menu

Trigger

Always-visible button in the top-right corner of every session tile. Click to open a floating menu.

Accessibility

  • button: aria-label="Session options", aria-haspopup="true".
  • Menu: role="menu" with role="menuitem" children.

Event Handling

Use event delegation — one click listener on the tile container, not one per button.

Menu Items

Menu item sets are defined as a data map keyed by view type, not procedural if/else chains. Adding a new view type means adding an entry to the map.

In the "All" view:

┌─────────────────────────┐
│  Add to View...     ▸   │
│  Hide                    │
│─────────────────────────│
│  Kill Session            │
└─────────────────────────┘

In a user-created view:

┌─────────────────────────┐
│  Add to View...     ▸   │
│  Remove from [ViewName]  │
│  Hide                    │
│─────────────────────────│
│  Kill Session            │
└─────────────────────────┘

View name in "Remove from [ViewName]" is truncated to 20 characters with ellipsis; full name in the title attribute.

In the "Hidden" view:

┌─────────────────────────┐
│  Unhide                  │
│  Unhide & Add to View... │
│─────────────────────────│
│  Kill Session            │
└─────────────────────────┘

"Unhide & Add to View..." makes the dual action explicit — selecting a view from the submenu unhides the session AND adds it to that view.

"Add to View" Submenu

Lists all user-created views (or all OTHER user views when in a user view). Views the session is already in are shown with a checkmark — clicking toggles membership. Clicking a view the session is not in adds it immediately (single PATCH). The flyout stays open after submenu actions so multiple views can be toggled.

Kill Session Confirmation

Clicking "Kill Session" replaces the menu item inline with "Kill? [Yes] [No]". No timeout — stays until click-outside closes the menu. On error (network failure, session already dead), shows "Failed" in the menu item for 2 seconds then reverts. If the session dies while the confirmation is showing, the menu closes.

Mobile

The tap opens a bottom action sheet instead of a floating menu. For "Add to View...", tapping opens a full-height picker sheet with checkboxes for each view plus a "Done" button.

Kill Session Transition

The existing kill-session UI location is removed when the flyout ships. The flyout is the single location for kill session.

Z-index / Stacking Context

Known implementation hazard. The flyout must render above tiles, headers, and any other overlapping elements. Left to implementer to resolve with appropriate z-index layering.

UI: Add Sessions Panel

Entry Point

When in a user-created view, an "Add Sessions" affordance in the grid area opens the panel. Also reachable from the tile flyout's "Add to View..." submenu.

Panel Design

An overlay panel showing all sessions NOT currently in the active view:

┌───────────────────────────────────┐
│  Add Sessions to "Work Project"   │
│───────────────────────────────────│
│  ☐ dev-server        [laptop]     │
│  ☐ api-logs          [pi]         │
│  ☐ build-runner      [desktop]    │
│  ☐ old-experiment    [laptop] dim │  ← hidden session, dimmed with badge
│     "This will make it visible"   │  ← inline disclosure
│───────────────────────────────────│
│           [Close]                 │
└───────────────────────────────────┘

Behaviors

  • Immediate commit — each checkbox change fires a PATCH immediately. No batch "Done" button; just a "Close" button. Same commit model as the flyout submenu.
  • Single flat list — no two-section cutline. Hidden sessions appear dimmed with a small "hidden" badge. Selecting a hidden session shows a brief inline note: "This will make it visible again."
  • Device name shown next to each session for disambiguation. Uses friendly device name from settings → falls back to hostname → falls back to truncated device_id. Shared utility function across all components.
  • List ordering — alphabetical, grouped by device (consistent with the grid's "grouped" mode).
  • Empty state — if all sessions are already in the view: "All sessions are already in this view."
  • Error handling — if a PATCH fails, show a brief error toast ("Couldn't save — try again"). Panel stays open, checkbox reverts.

Mobile

Panel becomes a full-screen sheet (same content, scrollable).

System View Restriction

"All" and "Hidden" are system views — the Add Sessions panel is not available for them. To add sessions to a user view from "All", use the tile flyout → "Add to View..." submenu.

Error Handling

Network Failures

  • PATCH failures (adding/removing from views, hiding, unhiding): UI element reverts to previous state. Brief error toast. No retry loop.
  • Kill session failure: "Failed" text in the flyout menu item for 2 seconds, then reverts to "Kill Session".
  • Settings sync failure: Existing 3-strike grace window for federation heartbeat applies. Views data is no different from other synced settings.

Data Invariant Violations

  • Post-sync repair: After every federation settings sync, run the mutual exclusion invariant check. If a session key appears in both hidden_sessions and any view, remove it from hidden_sessions (favor visibility).
  • Duplicate session keys in a view: Deduplicate silently on load.
  • Invalid view names: Reject at creation time via validation. If an invalid name arrives via federation sync, accept it (don't break sync) but flag it in the Manage Views panel.

Migration Failures

  • Per-remote migration is retried on each startup until successful.
  • Both old and new key formats are supported during the transition window so partial migration doesn't break functionality.

Testing Strategy

Unit Tests

  • View CRUD operations (create, rename, reorder, delete).
  • Mutual exclusion invariant: hiding removes from views, adding to view unhides.
  • Session key format migration (old positional → new device_id-based).
  • View name validation (empty, too long, reserved names, duplicates).
  • active_view fallback when referenced view is deleted.
  • Stale session key handling (silently omitted from render, retained in data).
  • Invariant repair after simulated concurrent edits.

Integration Tests

  • Federation sync round-trip: create a view on device A, verify it appears on device B.
  • active_view isolation: switching views on device A does NOT change device B.
  • Device identity: identity.json generated on first run, survives settings sync.
  • Migration: start with old-format session keys, verify rewrite after remote becomes reachable.
  • --reset-device-id CLI flag: generates new UUID, old session keys become stale.

Manual / Exploratory

  • Create views, switch between them, verify grid renders correct session subset.
  • Hide/unhide sessions from various views, verify mutual exclusion.
  • Kill session from flyout, verify removal from views and hidden.
  • Create session via + while in user view, verify auto-add.
  • Mobile: bottom sheets for dropdown and flyout, full-screen sheet for Add Sessions panel.
  • Fullscreen sidebar: view switcher dropdown works without leaving fullscreen.

Known Limitations

  1. Session renames break view membership. tmux rename-session changes the name component of the session key. The old key becomes stale in views; the renamed session appears in "All" but not in any previous views. Rename detection would require tmux hooks or a separate identity layer — both out of scope.

  2. Federation sync is atomic on the views array. Concurrent edits to different views on different devices may result in one set of changes being dropped (newer-wins on the whole array). Per-view keyed sync is the correct long-term solution but out of scope for the initial release.

  3. Number-key shortcuts stop at 9. Users with more than 7 user views don't get keyboard shortcuts for the extras. No hard cap on view count; the shortcuts just stop.