From 815c806da46eb71db0ff81f29585d297551bebf9 Mon Sep 17 00:00:00 2001 From: Ken Date: Wed, 27 May 2026 18:14:07 +0000 Subject: [PATCH] fix: stabilize frontend after Lit migration, fix new-session layout, graceful reconnect UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Frontend stabilization:** - Added CSS display: contents rules for custom element wrappers (session-grid, session-tile, sidebar-item, view-dropdown) that were defaulting to display: inline and breaking grid/flex layouts - Restored 20-line snapshot previews in session-tile and sidebar-item (were silently reduced to 12) - Fixed view-dropdown New View button missing data-action attribute, breaking create-view flow - Fixed sidebar dropdown close referencing non-existent #sidebar-view-dropdown-trigger element - Fixed sidebar renderSidebar() clear+rebuild pattern that ran every 2s poll — now diffs existing elements to avoid layout jank and scroll loss - Removed dead store.js import from index.html - Removed dead document-level .tile-options-btn delegation handler (superseded by Lit custom event chain) - Updated test for removed handler **Bug fix: new session from sidebar covers entire screen:** - Scoped zoom animation selector from unscoped [data-session] to .session-tile[data-session] within #session-grid - Added guard to skip zoom when overview grid not visible (creating from sidebar already in fullscreen, no grid tile to zoom from) **UX: graceful reconnect overlay:** - Added 1.5s grace period before showing Reconnecting... overlay - Brief disconnections (like service restarts) now invisible to user - Timer properly cleaned up in onopen, closeTerminal, and self-clearing callback **New documentation:** - Created RECOVERY_PLAN.md documenting COE, state diagrams, storyboards, data model Generated with Amplifier --- muxplex/frontend/RECOVERY_PLAN.md | 566 +++++++++++++++++++ muxplex/frontend/app.js | 51 +- muxplex/frontend/components/session-tile.js | 2 +- muxplex/frontend/components/sidebar-item.js | 2 +- muxplex/frontend/components/view-dropdown.js | 1 + muxplex/frontend/index.html | 2 +- muxplex/frontend/style.css | 9 + muxplex/frontend/terminal.js | 17 +- muxplex/tests/test_frontend_js.py | 23 +- 9 files changed, 640 insertions(+), 33 deletions(-) create mode 100644 muxplex/frontend/RECOVERY_PLAN.md diff --git a/muxplex/frontend/RECOVERY_PLAN.md b/muxplex/frontend/RECOVERY_PLAN.md new file mode 100644 index 0000000..b6cf455 --- /dev/null +++ b/muxplex/frontend/RECOVERY_PLAN.md @@ -0,0 +1,566 @@ +# muxplex Frontend Recovery Plan + +**Date:** 2026-05-27 +**Scope:** Stabilize the broken Lit migration; restore all core user workflows + +--- + +## 1. Correction of Errors (COE) + +### What happened + +Five commits were applied in sequence without live-testing the app between phases: + +``` +b9fe3d7 docs: add AGENTS.md +8687d72 Phase 1 Lit migration (session-tile, session-grid, sidebar-item, toast, pill, status) +e0c540b Phase 2 (hover-preview, bottom-sheet-switcher, search-filter) +9c86cf0 Phase 3 (view-dropdown, store.js) +b1f4569 P0 UX improvements (visual hierarchy, readable previews, search filter, default session) +``` + +Each phase introduced Lit web components to replace innerHTML-based rendering, but: + +1. **No manual browser testing between phases.** The test suite (1306 tests, all passing) + validates JavaScript function contracts in a Node.js harness -- it cannot catch DOM + rendering regressions, CSS layout breaks, or WebSocket lifecycle issues that only + manifest in a real browser. + +2. **Dual data paths were left alive.** Old `buildTileHTML()` and `buildSidebarHTML()` + remain in app.js for test compatibility, but the browser runs Lit components. Tests + validate the dead code path. The live path has zero automated coverage. + +3. **Incomplete component wiring.** `view-dropdown.js` fires custom events + (`view-new-input`) but `app.js` still queries for `[data-action="new-view"]` which + doesn't exist in the Lit-rendered DOM. The sidebar dropdown tries to find + `#sidebar-view-dropdown-trigger` which was replaced by an anonymous button inside + the Lit component. + +4. **Dead store.** `store.js` (`AppStore extends EventTarget`) was imported but never + wired to any component or to app.js. It sits in memory doing nothing, creating the + false impression of a reactive data layer. + +5. **Behavioral drift in tile rendering.** Snapshot line count changed from 20 to 12 + with no rationale or CSS compensation. Preview tiles show less terminal context. + +### Root cause + +**The migration was done by a code-generation agent that could run tests but never +opened the app in a browser.** All breakages are invisible to the test harness because +the test harness doesn't render DOM, doesn't connect WebSockets, and doesn't layout CSS. + +### Lessons + +- Test suite passing != app working. For UI work, browser verification is mandatory. +- Lit migrations must be atomic per component: migrate, test in browser, commit. + Not "4 phases batch-committed." +- Dead code paths that tests validate against must be removed or tests updated. + They create a false sense of safety. + +--- + +## 2. Application State Machine + +### Top-Level View States + +``` + ┌──────────────────────────┐ + │ │ + │ DASHBOARD (grid) │ + │ _viewMode = 'grid' │ + │ │ + │ ┌──────────────────┐ │ + │ │ session-grid │ │ + │ │ ├ session-tile │ │ + │ │ ├ session-tile │ │ + │ │ └ session-tile │ │ + │ └──────────────────┘ │ + └────────────┬─────────────┘ + │ + click tile / restore state + openSession(name, opts) + │ + ▼ + ┌──────────────────────────┐ + │ │ + │ TERMINAL (fullscreen) │ + │ _viewMode = 'fullscreen' │ + │ │ + │ ┌────────┬──────────┐ │ + │ │sidebar │ terminal │ │ + │ │ │ container │ │ + │ │ item │ │ │ + │ │ item │ xterm.js │ │ + │ │ item │ │ │ + │ └────────┴──────────┘ │ + └────────────┬─────────────┘ + │ + back btn / Escape / closeSession() + │ + ▼ + DASHBOARD (grid) +``` + +### Navigation State Transitions + +``` +┌─────────────┐ openSession() ┌──────────────┐ sidebar-select ┌──────────────┐ +│ DASHBOARD │ ───────────────► │ TERMINAL │ ──────────────► │ TERMINAL │ +│ (grid) │ │ (session A) │ │ (session B) │ +└─────────────┘ ◄─────────────── └──────────────┘ └──────┬───────┘ + closeSession() ▲ │ + │ sidebar-select │ + └──────────────────────────────────┘ +``` + +### WebSocket Connection Lifecycle + +``` + openSession(name) + │ + ▼ + POST /api/sessions/{name}/connect ← spawns ttyd + │ + ▼ + window._openTerminal(name, remoteId, fontSize) + │ + ▼ + createTerminal(fontSize) + │ + ▼ + connectWebSocket(name, remoteId) + │ + ▼ + new WebSocket(url, ['tty']) + │ + ┌────────┴────────┐ + │ │ + onopen onerror + │ │ + ▼ ▼ + send auth + dims console.warn + hide reconnect (falls through + overlay to onclose) + focus terminal + │ + ▼ + onmessage ◄──── continuous data ────┐ + │ │ + ▼ │ + _term.write(data) │ + reset reconnect counter on first msg │ + │ │ + └────────────────────────────────┘ + │ + onclose + │ + ▼ + ┌─ _currentSession null? ─── yes ──► DONE (intentional close) + │ + no + │ + ▼ + show reconnect overlay + _reconnectAttempts++ + delay = min(1s * 2^(n-1), 15s) + jitter + │ + ├── attempts < 2 ──► direct reconnect (new WebSocket) + │ + └── attempts >= 2 ──► POST /connect to respawn ttyd + wait 800ms + then new WebSocket +``` + +### Poll Loop Data Flow + +``` + every 2 seconds + │ + ▼ + GET /api/sessions (or /api/federation/sessions) + │ + ▼ + _currentSessions = response + │ + ┌────────┼─────────┬──────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + renderGrid renderSidebar updatePill updateTitle + │ │ updateFavicon + │ │ + ▼ ▼ + set props create/update + on + elements + │ + ▼ + Lit re-renders + + via repeat() +``` + +--- + +## 3. User Storyboards + +### Storyboard 1: Dashboard Load (cold start) + +``` +USER APP SERVER + │ │ │ + │ navigate to / │ │ + │ ──────────────────────────────► │ │ + │ │ GET /api/settings │ + │ │ ─────────────────────────────► + │ │ ◄───── settings JSON ───────│ + │ │ │ + │ │ GET /api/state │ + │ │ ─────────────────────────────► + │ │ ◄── { active_session } ─────│ + │ │ │ + │ │ GET /api/sessions │ + │ ◄── grid renders with tiles ── │ ◄── [session objects] ──────│ + │ (snapshot previews, │ │ + │ activity badges, │ start 2s poll loop │ + │ timestamps) │ start 5s heartbeat │ +``` + +### Storyboard 2: Open Terminal from Dashboard + +``` +USER APP SERVER + │ │ │ + │ click session tile "dev" │ │ + │ ──────────────────────────────► │ │ + │ │ tile-click event bubbles │ + │ │ openSession("dev") │ + │ │ │ + │ ◄── tile zoom animation ───── │ POST /sessions/dev/connect │ + │ tile expands to viewport │ ─────────────────────────────► + │ │ ◄── 200 (ttyd spawned) ────│ + │ │ │ + │ ◄── view swap: grid hidden ── │ _openTerminal("dev") │ + │ terminal view visible │ new WebSocket → ttyd │ + │ │ │ + │ ◄── terminal ready, │ ◄── ws data stream ─────────│ + │ cursor blinking │ │ + │ │ sidebar renders with │ + │ ◄── sidebar shows sessions ── │ current session highlighted │ +``` + +### Storyboard 3: Switch Sessions via Sidebar + +``` +USER APP SERVER + │ │ │ + │ click sidebar item "prod" │ │ + │ ──────────────────────────────► │ │ + │ │ sidebar-select event │ + │ │ openSession("prod") │ + │ │ │ + │ │ _closeTerminal() on "dev" │ + │ │ (sets _currentSession=null) │ + │ │ (ws.close, no reconnect) │ + │ │ │ + │ │ POST /sessions/prod/connect │ + │ │ ─────────────────────────────► + │ ◄── terminal switches to │ _openTerminal("prod") │ + │ "prod" session │ new WebSocket → ttyd │ + │ │ │ + │ ◄── sidebar updates, │ renderSidebar marks "prod" │ + │ "prod" highlighted │ as active │ +``` + +### Storyboard 4: Return to Dashboard + +``` +USER APP SERVER + │ │ │ + │ click back button / Escape │ │ + │ ──────────────────────────────► │ │ + │ │ closeSession() │ + │ │ _closeTerminal() │ + │ │ (ws closed, no reconnect) │ + │ │ │ + │ │ PATCH /api/state │ + │ │ { active_session: null } │ + │ │ ─────────────────────────────► + │ │ │ + │ ◄── grid view restored ────── │ view-expanded hidden │ + │ with fresh poll data │ view-overview visible │ +``` + +### Storyboard 5: Connection Lost / Reconnect + +``` +USER APP SERVER + │ │ │ + │ (network hiccup or ttyd dies) │ │ + │ │ ws.onclose fires │ + │ │ _currentSession != null │ + │ ◄── "Reconnecting..." overlay │ show #reconnect-overlay │ + │ │ attempt++ = 1 │ + │ │ wait 1s + jitter │ + │ │ new WebSocket (attempt 1) │ + │ │ │ + │ (if fails again) │ attempt++ = 2 │ + │ │ POST /sessions/x/connect │ + │ │ ────────────────────────────►│ + │ │ (respawn ttyd) │ + │ │ wait 800ms │ + │ │ new WebSocket (attempt 2) │ + │ │ │ + │ ◄── overlay disappears ────── │ ws.onopen → hide overlay │ + │ terminal resumes │ first data → reset counter │ +``` + +--- + +## 4. Lit Data Model: What It Should Be + +### Current Architecture (broken hybrid) + +``` +app.js (module-level vars) components/ (Lit) +┌──────────────────────┐ ┌──────────────────┐ +│ _currentSessions │───set───► │ session-grid │ +│ _viewingSession │ props │ .sessions │ +│ _viewMode │ │ .visibleSessions│ +│ _serverSettings │ │ │ +│ _activeView │───set───► │ sidebar-item (N) │ +│ _gridViewMode │ props │ .session │ +│ _pollFailCount │ │ .active │ +│ │ │ │ +│ startPolling() │ │ view-dropdown │ +│ openSession() │ │ .views │ +│ closeSession() │ │ .activeView │ +│ bindStaticListeners()│ │ │ +└──────────────────────┘ │ store.js ← DEAD │ + └──────────────────┘ + +Data flows ONE WAY: app.js → component props (push) +Events flow BACK: component events → app.js handlers (bubble) +``` + +### What's Wrong + +1. **No single source of truth.** State lives in app.js module variables. + Components receive props from `renderGrid()` / `renderSidebar()` calls. + There's no store, no subscription. If a poll renders the grid but + something prevents renderSidebar(), the sidebar is stale. + +2. **store.js exists but is unused.** It was meant to be the reactive layer + but nothing writes to it or reads from it. + +3. **renderSidebar() clears and rebuilds every poll.** Line 961: + `list.innerHTML = ''; list.appendChild(fragment);` -- this destroys all + elements and recreates them every 2 seconds, losing + scroll position, hover state, and any transient UI state. + +4. **Components can't self-update.** Since there's no store subscription, + components are purely passive. They render when app.js pushes props. + This means any bug in the push path = stale component. + +### Target Architecture (stabilized, minimal change) + +**Phase 1: Stabilize (this plan).** Don't introduce the store yet. Fix the +broken wiring so the existing push-props architecture works correctly. + +``` +app.js (owner of all state) + │ + ├── pollSessions() + │ └── _currentSessions = response + │ ├── renderGrid() → set props on + │ ├── renderSidebar() → DIFF elements (don't clear+rebuild) + │ ├── updatePill() → set props on + │ └── updateTitle() + │ + ├── openSession(name) + │ ├── _viewMode = 'fullscreen' + │ ├── _viewingSession = name + │ ├── view DOM swap + │ ├── _openTerminal(name) + │ └── renderSidebar() + │ + └── closeSession() + ├── _viewMode = 'grid' + ├── _viewingSession = null + ├── _closeTerminal() + └── view DOM swap +``` + +**Phase 2 (future): Introduce reactive store.** + +``` +store.js (EventTarget, single source of truth) + │ + ├── .sessions ← written by pollSessions() + ├── .viewMode ← written by openSession/closeSession + ├── .activeSession ← written by openSession/closeSession + ├── .activeView ← written by view switcher + ├── .settings ← written by loadServerSettings() + │ + └── .on('sessions', fn) ← components subscribe + .on('viewMode', fn) + .on('activeSession', fn) + +Components subscribe to store keys and self-update. +app.js becomes a thin orchestrator: event handlers + store.set(). +``` + +Phase 2 is NOT this plan. This plan is about making Phase 1 work. + +--- + +## 5. Diagnosed Issues + Fix Plan + +### CRITICAL: Issues causing user-visible breakage NOW + +#### C1. Sidebar clear+rebuild every poll (causes flicker, scroll loss, layout jank) + +**File:** `app.js:961` -- `list.innerHTML = ''; list.appendChild(fragment);` +**Problem:** Every 2-second poll destroys all `` elements and +creates new ones. This causes layout reflow, loses scroll position, may cause +the "tiles sized incorrectly" report if a render cycle catches mid-layout. +**Fix:** Diff existing elements by session key. Update props on existing +elements; only add/remove what actually changed. The keyed map is already +built (`existingItems`), the clear+rebuild at the end undoes the work. +**Original pattern:** Original app.js also used innerHTML, but it was a +single string assignment -- no element lifecycle to disrupt. + +#### C2. Sidebar uses `createRenderRoot() { return this; }` (light DOM) + +**File:** `sidebar-item.js:38` +**Problem:** Light DOM means the component renders directly into itself. The +existing CSS `.sidebar-item { height: 120px; }` should apply. But the Lit +element ITSELF (``) is an extra wrapper element that the +original CSS didn't account for. The CSS targets `.sidebar-item` (the article +inside), but the `` custom element wrapper has no explicit +`display` property, defaulting to `display: inline`. An inline wrapper +around a 120px-height block article creates layout havoc. +**Fix:** Add `sidebar-item { display: contents; }` or +`sidebar-item { display: block; }` to style.css. Same for `session-tile`. + +#### C3. wrapper element also lacks display rule + +**File:** `session-tile.js` (light DOM) +**Problem:** Same as C2. The `` custom element wrapping the +`
` has no CSS display rule. Grid layout +(`display: grid` on `.session-grid`) expects direct children to be grid +items. With `` as an inline wrapper, grid sizing breaks. +`` itself also needs a display rule. +**Fix:** Add CSS: +```css +session-grid { display: contents; } +session-tile { display: contents; } +sidebar-item { display: contents; } +``` +Or alternatively `display: block` if contents causes other issues. +This is THE most likely cause of the broken grid layout and sidebar sizing. + +#### C4. view-dropdown "New View" creation dead + +**File:** `app.js:1131` queries `[data-action="new-view"]`, `view-dropdown.js` +doesn't render that attribute. +**Fix:** Add `data-action="new-view"` to the button in view-dropdown.js, OR +have app.js listen for the `view-new-input` custom event instead. + +#### C5. Sidebar dropdown close references non-existent element + +**File:** `app.js:1227` queries `$('sidebar-view-dropdown-trigger')` which +doesn't exist (now rendered anonymously inside ``). +**Fix:** Use the `` element's `.open` property instead. + +### HIGH: Correctness issues + +#### H1. Snapshot line count reduced (20 → 12) without rationale + +**File:** `session-tile.js:82`, `sidebar-item.js:71` +**Problem:** Tiles show ~40% less terminal context. User reports "preview +thumbnail things missing." +**Fix:** Restore to 20 lines. The original 20 was tuned for 300px tile height +at the original font size. + +#### H2. Dead code: buildTileHTML / buildSidebarHTML still in app.js + +**File:** `app.js:516, 580` +**Problem:** Tests validate dead code. Live code is untested. +**Fix:** Remove dead functions. Update tests to validate Lit component +rendering instead, or add integration tests. + +#### H3. store.js imported but unused + +**File:** `index.html:288`, `components/store.js` +**Fix:** Remove the import. The store is a future concern. + +### MEDIUM: Behavioral drift + +#### M1. Duplicate utility functions (app.js globals + components/utils.js) + +`formatTimestamp`, `sessionPriority`, `ansiToHtml`, etc. exist in both. +**Fix:** Components import from utils.js. Remove duplicates from app.js +and have app.js import from utils.js too, OR keep app.js self-contained +(it's a non-module script, can't import ES modules). +Decision: Keep both for now. app.js is `defer` (not `type=module`). +The utils.js copy is canonical for components; app.js copy is canonical +for itself. Document the constraint. + +#### M2. Document-level `.tile-options-btn` delegated handler (dead) + +**File:** `app.js:3904` +The old handler still exists but `session-tile._onOptionsClick` calls +`stopPropagation()` so it never fires. +**Fix:** Remove the dead handler. + +--- + +## 6. Fix Execution Order + +Priority: restore usability first, clean up second. + +| # | Fix | Risk | Est. | +|---|-----|------|------| +| 1 | C3: Add `display: contents` CSS for custom element wrappers | LOW | 5 min | +| 2 | C1: Fix sidebar diff (stop clear+rebuild) | MED | 15 min | +| 3 | H1: Restore 20-line snapshot in tile + sidebar | LOW | 2 min | +| 4 | C4: Fix view-dropdown "New View" wiring | LOW | 5 min | +| 5 | C5: Fix sidebar dropdown close | LOW | 5 min | +| 6 | H3: Remove dead store.js import | LOW | 1 min | +| 7 | M2: Remove dead tile-options-btn handler | LOW | 1 min | +| 8 | H2: Remove dead buildTileHTML/buildSidebarHTML | MED | 10 min | +| 9 | Browser-verify all storyboards 1-5 | -- | 10 min | + +**Total estimated: ~55 minutes** + +Steps 1-3 should resolve the user's three reported symptoms: +- Grid/sidebar layout broken → C3 (display rules) +- Sidebar sizing wrong → C1 + C3 +- Missing preview thumbnails → H1 (line count) + +The "constantly hitting reconnect" report needs browser investigation. +terminal.js is unchanged. Possible causes: +- Layout break from C3 may cause terminal-container to have 0 dimensions, + which makes FitAddon report 0 cols/0 rows, which ttyd may reject +- The reconnect overlay may be visible due to CSS stacking issues from + the extra wrapper elements +- Investigate AFTER C3 fix is applied -- it may self-resolve + +--- + +## 7. Verification Criteria + +After all fixes, manually verify each storyboard: + +- [ ] Dashboard loads, all session tiles visible with terminal previews +- [ ] Tiles show ~20 lines of terminal content, readable +- [ ] Click tile → zoom animation → terminal opens → cursor blinks +- [ ] Terminal receives keystrokes and displays output +- [ ] No "Reconnecting..." overlay unless network is actually down +- [ ] Sidebar shows all sessions, current session highlighted +- [ ] Click different sidebar session → switches correctly +- [ ] Back button / Escape → returns to dashboard +- [ ] Settings dialog opens and closes +- [ ] View dropdown works (switch views, create new view) +- [ ] Mobile: tiles render in list mode, bottom sheet works +- [ ] Test suite still passes: `pytest muxplex/tests/ -x -q --timeout=30` \ No newline at end of file diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 11c309e..9ba5e82 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -949,14 +949,26 @@ function renderSidebar(sessions, currentSession, currentRemoteId) { } } - // Remove stale items + // Remove stale items that are no longer in the visible set existingItems.forEach(function(item, key) { if (!newKeys.has(key)) item.remove(); }); - // Clear and rebuild - list.innerHTML = ''; - list.appendChild(fragment); + // Diff-append: only replace children if the fragment differs from current DOM. + // This avoids destroying all sidebar-item elements every 2s poll, which would + // lose scroll position, hover state, and cause layout jank. + var fragmentChildren = Array.from(fragment.childNodes); + var listChildren = Array.from(list.childNodes); + var needsRebuild = fragmentChildren.length !== listChildren.length; + if (!needsRebuild) { + for (var k = 0; k < fragmentChildren.length; k++) { + if (fragmentChildren[k] !== listChildren[k]) { needsRebuild = true; break; } + } + } + if (needsRebuild) { + list.innerHTML = ''; + list.appendChild(fragment); + } } const SIDEBAR_NARROW_THRESHOLD = 960; @@ -1223,9 +1235,8 @@ function showSidebarNewViewInput() { input.focus(); function closeSidebarDropdown() { - menu.classList.add('hidden'); - var trigger = $('sidebar-view-dropdown-trigger'); - if (trigger) trigger.setAttribute('aria-expanded', 'false'); + var sidebarDropdown = $('sidebar-view-dropdown'); + if (sidebarDropdown) sidebarDropdown.open = false; } input.addEventListener('keydown', function(e) { @@ -2834,9 +2845,19 @@ async function openSession(name, opts = {}) { const nameEl = $('expanded-session-name'); if (nameEl) nameEl.textContent = name; - // Zoom animation: pin tile at current position, then animate to full viewport - // Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from - const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`); + // Zoom animation: pin tile at current position, then animate to full viewport. + // Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from. + // MUST scope to #session-grid to avoid matching sidebar-item elements that share + // the data-session attribute — an unscoped querySelector would match the sidebar + // item first (it appears earlier in DOM order), applying position:fixed + 100vw/100vh + // to the sidebar item and covering the entire screen. + // Also skip when the overview grid is not visible (e.g. creating a session from the + // sidebar while already in terminal view). + const gridEl = $('session-grid'); + const overviewVisible = $('view-overview') && $('view-overview').style.display !== 'none'; + const tile = (opts.skipAnimation || !overviewVisible) + ? null + : (gridEl ? gridEl.querySelector(`.session-tile[data-session="${name}"]`) : null); if (tile) { const rect = tile.getBoundingClientRect(); tile.style.position = 'fixed'; @@ -3900,12 +3921,10 @@ function killSession(name, remoteId) { * Called once after restoreState() resolves. */ function bindStaticEventListeners() { - // Delegated ⋮ options button handler (tiles are re-rendered each poll) - document.addEventListener('click', function(e) { - var optionsBtn = e.target.closest && e.target.closest('.tile-options-btn'); - if (!optionsBtn) return; - openFlyoutMenu(optionsBtn); - }); + // NOTE: Tile options are handled via session-tile's tile-options → session-grid's + // session-options custom event chain. The old document-level delegated handler + // was removed because session-tile._onOptionsClick() calls stopPropagation(), + // making the delegated handler a dead code path. on($('back-btn'), 'click', closeSession); diff --git a/muxplex/frontend/components/session-tile.js b/muxplex/frontend/components/session-tile.js index 69f0eda..b98bc64 100644 --- a/muxplex/frontend/components/session-tile.js +++ b/muxplex/frontend/components/session-tile.js @@ -76,7 +76,7 @@ export class SessionTile extends LitElement { get _snapshotHtml() { const snapshot = this.session.snapshot || ''; - const lineCount = this.viewMode === 'fit' ? -80 : -12; + const lineCount = this.viewMode === 'fit' ? -80 : -20; // Trim trailing blank lines first (see buildTileHTML comment in app.js) const allLines = snapshot.split('\n'); while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { diff --git a/muxplex/frontend/components/sidebar-item.js b/muxplex/frontend/components/sidebar-item.js index 3b503ce..803e411 100644 --- a/muxplex/frontend/components/sidebar-item.js +++ b/muxplex/frontend/components/sidebar-item.js @@ -70,7 +70,7 @@ export class SidebarItem extends LitElement { while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { allLines.pop(); } - return ansiToHtml(allLines.slice(-12).join('\n')); + return ansiToHtml(allLines.slice(-20).join('\n')); } _onClick(e) { diff --git a/muxplex/frontend/components/view-dropdown.js b/muxplex/frontend/components/view-dropdown.js index 437a9e1..fbba971 100644 --- a/muxplex/frontend/components/view-dropdown.js +++ b/muxplex/frontend/components/view-dropdown.js @@ -150,6 +150,7 @@ export class ViewDropdown extends LitElement { `; diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index cd4e396..1a38ad3 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -285,7 +285,7 @@ import '/components/hover-preview.js'; import '/components/bottom-sheet-switcher.js'; import '/components/session-grid.js'; - import '/components/store.js'; + import '/components/view-dropdown.js'; import '/components/search-filter.js'; diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index b50ce07..a1e42bd 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -56,6 +56,15 @@ --preview-font-size: 12px; } +/* Custom element wrappers — Lit components using light DOM render children + into themselves, but the custom element tag itself defaults to display:inline. + Grid and flex layouts need these to be transparent or block-level. */ +session-grid, +session-tile, +sidebar-item { display: contents; } + +view-dropdown { display: block; } + /* Inline SVG icons — ensure consistent vertical alignment */ .header-btn svg, .back-btn svg, diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index d0acddc..c794e22 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -6,6 +6,7 @@ let _term = null; let _fitAddon = null; let _ws = null; let _reconnectTimer = null; +let _overlayTimer = null; // grace period before showing reconnect overlay let _currentSession = null; let _vpHandler = null; let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn @@ -129,6 +130,8 @@ function connectWebSocket(name, remoteId) { // browser 'open' event fires as soon as the proxy accepts — not when ttyd // is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead, // reset on first data message (proves ttyd is alive and relaying). + // Cancel any pending overlay show (grace period may not have elapsed yet) + if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; } if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); // Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY ws.send(JSON.stringify({ AuthToken: '' })); @@ -167,7 +170,14 @@ function connectWebSocket(name, remoteId) { ws.addEventListener('close', function() { if (ws !== _ws) return; // stale connection — don't reconnect for old sockets if (!_currentSession) return; // intentional close — don't reconnect - if (reconnectOverlay) reconnectOverlay.classList.remove('hidden'); + // Grace period: delay showing the overlay so brief reconnects (e.g. service + // restart) are invisible to the user. If onopen fires within the grace window, + // it cancels _overlayTimer and the user never sees "Reconnecting...". + if (_overlayTimer) clearTimeout(_overlayTimer); + _overlayTimer = setTimeout(function() { + if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden'); + _overlayTimer = null; + }, 1500); _reconnectAttempts++; // Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd. var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000); @@ -546,6 +556,11 @@ function closeTerminal() { _reconnectTimer = null; } + if (_overlayTimer) { + clearTimeout(_overlayTimer); + _overlayTimer = null; + } + if (_ws) { _ws.close(); _ws = null; diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index a59eb63..881a92b 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -4006,24 +4006,21 @@ def test_tile_click_handler_guards_options_btn() -> None: def test_flyout_delegation_handler_no_stop_propagation() -> None: - """bindStaticEventListeners .tile-options-btn delegation must NOT call e.stopPropagation(). + """Tile options are handled via session-tile custom event chain, not document delegation. - BUG: e.stopPropagation() at document level is a no-op (document is the top of the - bubble chain). It created a false sense of correctness while doing nothing. + The old document-level .tile-options-btn delegated handler was removed because + session-tile._onOptionsClick() calls stopPropagation(), making the document handler + dead code. Tile options now flow: tile-options → session-options custom events. """ - # Extract the tile-options-btn delegation handler block from bindStaticEventListeners bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[ 0 ] - # Find the section with tile-options-btn - assert "tile-options-btn" in bind_body, ( - "bindStaticEventListeners must have .tile-options-btn delegation" - ) - # The delegation block should not have stopPropagation - tile_opts_section = bind_body.split("tile-options-btn")[1].split("});")[0] - assert "stopPropagation" not in tile_opts_section, ( - "The .tile-options-btn delegation handler must not call e.stopPropagation() — " - "it is a no-op at document level and creates false sense of correctness" + # The old document-level delegated handler should NOT be present — + # it was replaced by the session-tile → session-grid custom event chain. + # A comment explaining the removal should be present instead. + assert "session-options" in bind_body or "session-tile" in bind_body, ( + "bindStaticEventListeners should document that tile options use the " + "session-tile custom event chain" )