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
20 KiB
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_instancesentries gain adevice_idfield, populated by querying the remote's/api/instance-infoendpoint./api/instance-inforesponse extended to includedevice_id:
{
"name": "my-laptop",
"device_id": "a1b2c3d4-...",
"version": "0.3.7",
"federation_enabled": true
}
active_remote_idinstate.jsonchanges from integer index todevice_idstring.- All frontend session key construction/parsing (
sessionKey,data-remote-id, API URL construction, bell tracking, etc.) updated to usedevice_idinstead 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:
- On startup, check each remote instance entry: if it lacks a
device_id, query its/api/instance-info. - If the remote is reachable, store its
device_idand rewrite any session keys in state/settings that used the old positional index. - If unreachable, skip that remote. Retry on next startup.
- Track migration status per-remote so a crash mid-migration doesn't re-run already-migrated remotes.
- 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"]
}
viewsis an ordered list of{name: string, sessions: string[]}objects. Array order = dropdown order.- Session keys use
device_id:nameformat uniformly. viewsandhidden_sessionsare inSYNCABLE_KEYS(synced across federation).- The old
gridViewModesetting's"filtered"value is removed. Only"flat"and"grouped"remain.
State (~/.local/share/muxplex/state.json)
New field:
{
"active_view": "all"
}
active_viewis"all"(default),"hidden", or a view name string.- Stored in
state.json, NOTsettings.json— per-device, NOT synced via federation. Prevents switching views on one device from changing the view on another device. - If
active_viewreferences 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_sessionsautomatically (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_sessionsand any view (possible due to concurrent writes on different devices), remove it fromhidden_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
gridViewModeretains only"flat"and"grouped"values. The"filtered"value is removed entirely.flat/groupedis 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-9jump directly: 1=All, 2–8=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"withrole="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_sessionsand any view, remove it fromhidden_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_viewfallback 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_viewisolation: switching views on device A does NOT change device B.- Device identity:
identity.jsongenerated on first run, survives settings sync. - Migration: start with old-format session keys, verify rewrite after remote becomes reachable.
--reset-device-idCLI 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
-
Session renames break view membership.
tmux rename-sessionchanges 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. -
Federation sync is atomic on the
viewsarray. 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. -
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.