fix: stabilize frontend after Lit migration, fix new-session layout, graceful reconnect UX
**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 <https://github.com/microsoft/amplifier>
This commit is contained in:
@@ -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 <sidebar-item>
|
||||||
|
<session-grid> elements
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Lit re-renders
|
||||||
|
<session-tile>
|
||||||
|
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
|
||||||
|
<sidebar-item> 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 <session-grid>
|
||||||
|
│ ├── renderSidebar() → DIFF <sidebar-item> elements (don't clear+rebuild)
|
||||||
|
│ ├── updatePill() → set props on <session-pill>
|
||||||
|
│ └── 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 `<sidebar-item>` 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 <sidebar-item> 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 (`<sidebar-item>`) is an extra wrapper element that the
|
||||||
|
original CSS didn't account for. The CSS targets `.sidebar-item` (the article
|
||||||
|
inside), but the `<sidebar-item>` 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. <session-tile> wrapper element also lacks display rule
|
||||||
|
|
||||||
|
**File:** `session-tile.js` (light DOM)
|
||||||
|
**Problem:** Same as C2. The `<session-tile>` custom element wrapping the
|
||||||
|
`<article class="session-tile">` has no CSS display rule. Grid layout
|
||||||
|
(`display: grid` on `.session-grid`) expects direct children to be grid
|
||||||
|
items. With `<session-tile>` as an inline wrapper, grid sizing breaks.
|
||||||
|
`<session-grid>` 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 `<view-dropdown>`).
|
||||||
|
**Fix:** Use the `<view-dropdown>` 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`
|
||||||
+35
-16
@@ -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) {
|
existingItems.forEach(function(item, key) {
|
||||||
if (!newKeys.has(key)) item.remove();
|
if (!newKeys.has(key)) item.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear and rebuild
|
// Diff-append: only replace children if the fragment differs from current DOM.
|
||||||
list.innerHTML = '';
|
// This avoids destroying all sidebar-item elements every 2s poll, which would
|
||||||
list.appendChild(fragment);
|
// 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;
|
const SIDEBAR_NARROW_THRESHOLD = 960;
|
||||||
@@ -1223,9 +1235,8 @@ function showSidebarNewViewInput() {
|
|||||||
input.focus();
|
input.focus();
|
||||||
|
|
||||||
function closeSidebarDropdown() {
|
function closeSidebarDropdown() {
|
||||||
menu.classList.add('hidden');
|
var sidebarDropdown = $('sidebar-view-dropdown');
|
||||||
var trigger = $('sidebar-view-dropdown-trigger');
|
if (sidebarDropdown) sidebarDropdown.open = false;
|
||||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener('keydown', function(e) {
|
input.addEventListener('keydown', function(e) {
|
||||||
@@ -2834,9 +2845,19 @@ async function openSession(name, opts = {}) {
|
|||||||
const nameEl = $('expanded-session-name');
|
const nameEl = $('expanded-session-name');
|
||||||
if (nameEl) nameEl.textContent = name;
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
|
||||||
// Zoom animation: pin tile at current position, then animate to full viewport
|
// Zoom animation: pin tile at current position, then animate to full viewport.
|
||||||
// Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from
|
// Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from.
|
||||||
const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`);
|
// 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) {
|
if (tile) {
|
||||||
const rect = tile.getBoundingClientRect();
|
const rect = tile.getBoundingClientRect();
|
||||||
tile.style.position = 'fixed';
|
tile.style.position = 'fixed';
|
||||||
@@ -3900,12 +3921,10 @@ function killSession(name, remoteId) {
|
|||||||
* Called once after restoreState() resolves.
|
* Called once after restoreState() resolves.
|
||||||
*/
|
*/
|
||||||
function bindStaticEventListeners() {
|
function bindStaticEventListeners() {
|
||||||
// Delegated ⋮ options button handler (tiles are re-rendered each poll)
|
// NOTE: Tile options are handled via session-tile's tile-options → session-grid's
|
||||||
document.addEventListener('click', function(e) {
|
// session-options custom event chain. The old document-level delegated handler
|
||||||
var optionsBtn = e.target.closest && e.target.closest('.tile-options-btn');
|
// was removed because session-tile._onOptionsClick() calls stopPropagation(),
|
||||||
if (!optionsBtn) return;
|
// making the delegated handler a dead code path.
|
||||||
openFlyoutMenu(optionsBtn);
|
|
||||||
});
|
|
||||||
|
|
||||||
on($('back-btn'), 'click', closeSession);
|
on($('back-btn'), 'click', closeSession);
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class SessionTile extends LitElement {
|
|||||||
|
|
||||||
get _snapshotHtml() {
|
get _snapshotHtml() {
|
||||||
const snapshot = this.session.snapshot || '';
|
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)
|
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
|
||||||
const allLines = snapshot.split('\n');
|
const allLines = snapshot.split('\n');
|
||||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class SidebarItem extends LitElement {
|
|||||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||||
allLines.pop();
|
allLines.pop();
|
||||||
}
|
}
|
||||||
return ansiToHtml(allLines.slice(-12).join('\n'));
|
return ansiToHtml(allLines.slice(-20).join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
_onClick(e) {
|
_onClick(e) {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export class ViewDropdown extends LitElement {
|
|||||||
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
||||||
@click=${this._onManageAll}>Manage All Views\u2026</button>
|
@click=${this._onManageAll}>Manage All Views\u2026</button>
|
||||||
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
|
||||||
|
data-action="new-view"
|
||||||
@click=${this._onNewView}>+ New View</button>
|
@click=${this._onNewView}>+ New View</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
import '/components/hover-preview.js';
|
import '/components/hover-preview.js';
|
||||||
import '/components/bottom-sheet-switcher.js';
|
import '/components/bottom-sheet-switcher.js';
|
||||||
import '/components/session-grid.js';
|
import '/components/session-grid.js';
|
||||||
import '/components/store.js';
|
|
||||||
import '/components/view-dropdown.js';
|
import '/components/view-dropdown.js';
|
||||||
import '/components/search-filter.js';
|
import '/components/search-filter.js';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -56,6 +56,15 @@
|
|||||||
--preview-font-size: 12px;
|
--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 */
|
/* Inline SVG icons — ensure consistent vertical alignment */
|
||||||
.header-btn svg,
|
.header-btn svg,
|
||||||
.back-btn svg,
|
.back-btn svg,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let _term = null;
|
|||||||
let _fitAddon = null;
|
let _fitAddon = null;
|
||||||
let _ws = null;
|
let _ws = null;
|
||||||
let _reconnectTimer = null;
|
let _reconnectTimer = null;
|
||||||
|
let _overlayTimer = null; // grace period before showing reconnect overlay
|
||||||
let _currentSession = null;
|
let _currentSession = null;
|
||||||
let _vpHandler = null;
|
let _vpHandler = null;
|
||||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
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
|
// 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,
|
// is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead,
|
||||||
// reset on first data message (proves ttyd is alive and relaying).
|
// 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');
|
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
|
||||||
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
|
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
|
||||||
ws.send(JSON.stringify({ AuthToken: '' }));
|
ws.send(JSON.stringify({ AuthToken: '' }));
|
||||||
@@ -167,7 +170,14 @@ function connectWebSocket(name, remoteId) {
|
|||||||
ws.addEventListener('close', function() {
|
ws.addEventListener('close', function() {
|
||||||
if (ws !== _ws) return; // stale connection — don't reconnect for old sockets
|
if (ws !== _ws) return; // stale connection — don't reconnect for old sockets
|
||||||
if (!_currentSession) return; // intentional close — don't reconnect
|
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++;
|
_reconnectAttempts++;
|
||||||
// Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd.
|
// 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);
|
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
|
||||||
@@ -546,6 +556,11 @@ function closeTerminal() {
|
|||||||
_reconnectTimer = null;
|
_reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_overlayTimer) {
|
||||||
|
clearTimeout(_overlayTimer);
|
||||||
|
_overlayTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (_ws) {
|
if (_ws) {
|
||||||
_ws.close();
|
_ws.close();
|
||||||
_ws = null;
|
_ws = null;
|
||||||
|
|||||||
@@ -4006,24 +4006,21 @@ def test_tile_click_handler_guards_options_btn() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_flyout_delegation_handler_no_stop_propagation() -> 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
|
The old document-level .tile-options-btn delegated handler was removed because
|
||||||
bubble chain). It created a false sense of correctness while doing nothing.
|
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 ")[
|
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
# Find the section with tile-options-btn
|
# The old document-level delegated handler should NOT be present —
|
||||||
assert "tile-options-btn" in bind_body, (
|
# it was replaced by the session-tile → session-grid custom event chain.
|
||||||
"bindStaticEventListeners must have .tile-options-btn delegation"
|
# A comment explaining the removal should be present instead.
|
||||||
)
|
assert "session-options" in bind_body or "session-tile" in bind_body, (
|
||||||
# The delegation block should not have stopPropagation
|
"bindStaticEventListeners should document that tile options use the "
|
||||||
tile_opts_section = bind_body.split("tile-options-btn")[1].split("});")[0]
|
"session-tile custom event chain"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user