fix: stabilize frontend after Lit migration, fix new-session layout, graceful reconnect UX
CI / test (3.13) (push) Failing after 12m40s
CI / test (3.12) (push) Failing after 12m42s
CI / test (3.11) (push) Failing after 12m44s

**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:
Ken
2026-05-27 18:14:07 +00:00
parent b1f4569608
commit 815c806da4
9 changed files with 640 additions and 33 deletions
+566
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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() === '') {
+1 -1
View File
@@ -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) {
@@ -150,6 +150,7 @@ export class ViewDropdown extends LitElement {
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
@click=${this._onManageAll}>Manage All Views\u2026</button>
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
data-action="new-view"
@click=${this._onNewView}>+ New View</button>
</div>
`;
+1 -1
View File
@@ -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';
</script>
+9
View File
@@ -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,
+16 -1
View File
@@ -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;
+10 -13
View File
@@ -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"
)