Files
muxplex/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md
T
Brian Krabach 8234e2ec05 refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
2026-03-27 15:06:00 -07:00

63 KiB

Web-Tmux Dashboard Phase 2b — JavaScript Implementation Plan

Execution: Use the subagent-driven-development workflow to implement this plan.

Goal: Implement complete JavaScript behavior for the tmux web dashboard — session polling, tile rendering, bell notifications, zoom transitions, command palette, and xterm.js terminal with WebSocket connection.

Architecture: Vanilla ES5-compatible JavaScript in app.js and terminal.js. Pure functions at the top of app.js are exported conditionally for Node.js unit testing — the export block lives at the very bottom of app.js and remains there across all tasks. DOM manipulation uses getElementById. xterm.js and FitAddon are loaded from CDN in index.html. terminal.js exposes openTerminal/closeTerminal via window._openTerminal/window._closeTerminal to avoid circular dependency with app.js.

Tech Stack: Vanilla JavaScript (no framework, no build step), xterm.js 5.3.0 + xterm-addon-fit 0.8.0 (CDN), Node.js 18+ node:test (unit tests for pure functions only), WebSocket API (native browser).


Quick Reference

Files changed What
frontend/tests/test_app.mjs Node.js unit tests — pure functions only
frontend/app.js Complete app — polling, rendering, keyboard, transitions
frontend/terminal.js xterm.js Terminal + WebSocket + visualViewport

Test command (tasks 1-3): node --test frontend/tests/test_app.mjs

No Python tests change. The 118 pytest tests from Phase 2a continue to pass and are never touched.

Architecture Note: How app.js Grows Across Tasks

Tasks 1-3 build the pure-function layer at the top of app.js. Tasks 4-15 add runtime/DOM functions above the conditional export block.

The conditional export block is written in Task 1 and stays at the very bottom of app.js unchanged for the rest of the plan. Every subsequent task that modifies app.js adds code above that block.

The final structure of app.js (from top to bottom):

  1. Pure functions (Tasks 1-3)
  2. State variables (Task 4)
  3. DOM helpers / api wrapper (Task 4)
  4. initDeviceId, trackInteraction, DOMContentLoaded (Task 4)
  5. restoreState (Task 5)
  6. startPolling, pollSessions, setConnectionStatus (Task 6)
  7. renderGrid, buildTileHTML, escapeHtml, isMobile (Task 7)
  8. requestNotificationPermission, handleBellTransitions (Task 8)
  9. startHeartbeat, sendHeartbeat (Task 9)
  10. openSession, closeSession, showToast (Task 10)
  11. bindStaticEventListeners, palette functions (Task 11)
  12. openBottomSheet, closeBottomSheet, renderSheetList (Task 15)
  13. Conditional CommonJS export block (Task 1 — never move this)

Task 1: Test infrastructure + minimal app.js stub

Files:

  • Create: frontend/tests/test_app.mjs
  • Modify: frontend/app.js (replace single-line stub)

Step 1: Write the failing test

Create file frontend/tests/test_app.mjs:

// frontend/tests/test_app.mjs
// Node.js 18+ built-in test runner. Run: node --test frontend/tests/test_app.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);

// Stub browser globals that app.js references at module scope.
// Must be set BEFORE require('app.js') runs.
global.document = {
  getElementById: () => null,
  querySelector: () => null,
  querySelectorAll: () => [],
  addEventListener: () => {},
};
global.window = {
  innerWidth: 1280,
  localStorage: { getItem: () => null, setItem: () => {} },
};
global.Notification = { permission: 'denied', requestPermission: async () => 'denied' };
global.navigator = { userAgent: 'TestAgent/1.0' };

// Load app.js (uses conditional CommonJS export).
const app = require(path.join(__dirname, '../app.js'));

const {
  formatTimestamp,
  sessionPriority,
  sortByPriority,
  filterByQuery,
  detectBellTransitions,
  generateDeviceId,
  buildHeartbeatPayload,
} = app;

// ── Task 1: smoke test ──────────────────────────────────────────────────────
test('app module loads without error', () => {
  assert.ok(typeof formatTimestamp === 'function', 'formatTimestamp should be exported');
  assert.ok(typeof sessionPriority === 'function', 'sessionPriority should be exported');
  assert.ok(typeof sortByPriority === 'function', 'sortByPriority should be exported');
  assert.ok(typeof filterByQuery === 'function', 'filterByQuery should be exported');
  assert.ok(typeof detectBellTransitions === 'function', 'detectBellTransitions should be exported');
  assert.ok(typeof generateDeviceId === 'function', 'generateDeviceId should be exported');
  assert.ok(typeof buildHeartbeatPayload === 'function', 'buildHeartbeatPayload should be exported');
});

Step 2: Run the test — expect FAIL

node --test frontend/tests/test_app.mjs

Expected failure (existing app.js is a comment, not a module):

TypeError: Cannot read properties of undefined (reading 'formatTimestamp')

or similar. Good — the test correctly fails against the stub.


Step 3: Replace frontend/app.js with minimal stubs + export block

Replace the entire file with:

/* app.js — tmux web dashboard (Phase 2b)
 * Pure functions first, then runtime code, then conditional export at bottom.
 * NEVER move the export block — it must remain the last lines of this file. */

// ── Pure functions ──────────────────────────────────────────────────────────
// These stubs will be replaced in Tasks 2-3. They exist now so the export
// block can reference them and Task 1's smoke test passes.

function formatTimestamp(ts) { return ts == null ? '—' : ''; }
function sessionPriority(session) { return 'idle'; }
function sortByPriority(sessions) { return sessions.slice(); }
function filterByQuery(sessions, query) { return sessions; }
function detectBellTransitions(prevSessions, nextSessions) { return []; }
function generateDeviceId() { return 'dev-0'; }
function buildHeartbeatPayload(deviceId, viewingSession, viewMode, lastInteractionAt) {
  return { device_id: deviceId, label: 'stub', viewing_session: viewingSession,
           view_mode: viewMode, last_interaction_at: lastInteractionAt };
}

// ── Conditional CommonJS export for Node.js unit tests ─────────────────────
// In the browser, `module` is undefined — this block never executes.
// KEEP THIS BLOCK AT THE VERY BOTTOM OF app.js. NEVER MOVE IT.
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    formatTimestamp,
    sessionPriority,
    sortByPriority,
    filterByQuery,
    detectBellTransitions,
    generateDeviceId,
    buildHeartbeatPayload,
  };
}

Step 4: Run the test — expect PASS

node --test frontend/tests/test_app.mjs

Expected output (Node.js TAP format):

TAP version 13
# Subtest: app module loads without error
ok 1 - app module loads without error
...
# tests 1
# pass 1
# fail 0

Step 5: Commit

git add frontend/tests/test_app.mjs frontend/app.js
git commit -m "feat: add Node.js test infrastructure for frontend pure functions"

Task 2: Pure functions — formatTimestamp, sessionPriority, sortByPriority

Files:

  • Modify: frontend/tests/test_app.mjs (add tests after Task 1 smoke test)
  • Modify: frontend/app.js (replace first 3 stubs with real implementations)

Step 1: Add failing tests to test_app.mjs

Add these test blocks after the Task 1 smoke test block (before the end of the file):

// ── Task 2: formatTimestamp ─────────────────────────────────────────────────
test('formatTimestamp: null returns em-dash', () => {
  assert.strictEqual(formatTimestamp(null), '—');
  assert.strictEqual(formatTimestamp(undefined), '—');
});
test('formatTimestamp: seconds ago', () => {
  const now = Date.now() / 1000;
  assert.match(formatTimestamp(now - 5), /^5s ago$/);
});
test('formatTimestamp: minutes ago', () => {
  const now = Date.now() / 1000;
  assert.match(formatTimestamp(now - 125), /^2m ago$/);
});
test('formatTimestamp: hours ago', () => {
  const now = Date.now() / 1000;
  assert.match(formatTimestamp(now - 7260), /^2h ago$/);
});

// ── Task 2: sessionPriority ─────────────────────────────────────────────────
test('sessionPriority: returns bell when unseen_count > 0 and seen_at is null', () => {
  const s = { bell: { last_fired_at: 100, seen_at: null, unseen_count: 1 } };
  assert.strictEqual(sessionPriority(s), 'bell');
});
test('sessionPriority: returns bell when last_fired_at > seen_at', () => {
  const s = { bell: { last_fired_at: 200, seen_at: 100, unseen_count: 2 } };
  assert.strictEqual(sessionPriority(s), 'bell');
});
test('sessionPriority: returns idle when unseen_count is 0', () => {
  const s = { bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } };
  assert.strictEqual(sessionPriority(s), 'idle');
});
test('sessionPriority: returns idle when seen_at >= last_fired_at', () => {
  const s = { bell: { last_fired_at: 100, seen_at: 100, unseen_count: 1 } };
  assert.strictEqual(sessionPriority(s), 'idle');
});

// ── Task 2: sortByPriority ──────────────────────────────────────────────────
test('sortByPriority: bell sessions sort before idle sessions', () => {
  const sessions = [
    { name: 'idle', bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } },
    { name: 'bell', bell: { last_fired_at: 100, seen_at: null, unseen_count: 2 } },
  ];
  const sorted = sortByPriority(sessions);
  assert.strictEqual(sorted[0].name, 'bell');
  assert.strictEqual(sorted[1].name, 'idle');
});
test('sortByPriority: does not mutate input array', () => {
  const sessions = [
    { name: 'a', bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } },
  ];
  const orig = sessions;
  sortByPriority(sessions);
  assert.strictEqual(sessions, orig, 'original array reference must not change');
});

Step 2: Run tests — expect FAIL

node --test frontend/tests/test_app.mjs

Expected: the smoke test passes, but the new tests fail because:

  • formatTimestamp(now - 5) returns '' instead of '5s ago'
  • sessionPriority always returns 'idle'
  • sortByPriority returns input array reference (mutates), not a sorted copy

Step 3: Replace the first 3 stubs in frontend/app.js

Replace the three stub function bodies (leave everything else untouched, especially the export block):

function formatTimestamp(ts) {
  if (ts == null) return '—';
  const diff = Math.max(0, Math.floor(Date.now() / 1000 - ts));
  if (diff < 60) return `${diff}s ago`;
  if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
  return `${Math.floor(diff / 3600)}h ago`;
}

function sessionPriority(session) {
  const b = session.bell;
  const hasBell = b &&
    b.unseen_count > 0 &&
    (b.seen_at === null || b.last_fired_at > b.seen_at);
  if (hasBell) return 'bell';
  return 'idle';
}

function sortByPriority(sessions) {
  const order = { bell: 0, active: 1, idle: 2 };
  return sessions.slice().sort(
    (a, b) => (order[sessionPriority(a)] ?? 2) - (order[sessionPriority(b)] ?? 2)
  );
}

Step 4: Run tests — expect PASS

node --test frontend/tests/test_app.mjs

Expected:

# tests 11
# pass 11
# fail 0

Step 5: Commit

git add frontend/app.js frontend/tests/test_app.mjs
git commit -m "feat: add formatTimestamp, sessionPriority, sortByPriority pure functions"

Task 3: Pure functions — filterByQuery, detectBellTransitions, generateDeviceId, buildHeartbeatPayload

Files:

  • Modify: frontend/tests/test_app.mjs (add tests)
  • Modify: frontend/app.js (replace remaining 4 stubs with real implementations)

Step 1: Add failing tests to test_app.mjs

Append these blocks after the Task 2 tests:

// ── Task 3: filterByQuery ───────────────────────────────────────────────────
test('filterByQuery: empty query returns all sessions', () => {
  const sessions = [{ name: 'a' }, { name: 'b' }];
  assert.strictEqual(filterByQuery(sessions, '').length, 2);
  assert.strictEqual(filterByQuery(sessions, null).length, 2);
});
test('filterByQuery: matches by substring, case-insensitive', () => {
  const sessions = [{ name: 'work' }, { name: 'web-tmux' }, { name: 'logs' }];
  const result = filterByQuery(sessions, 'W').map(s => s.name);
  assert.deepStrictEqual(result, ['work', 'web-tmux']);
});
test('filterByQuery: no match returns empty array', () => {
  const sessions = [{ name: 'alpha' }, { name: 'beta' }];
  assert.strictEqual(filterByQuery(sessions, 'zzz').length, 0);
});

// ── Task 3: detectBellTransitions ──────────────────────────────────────────
test('detectBellTransitions: detects new bell on existing session', () => {
  const prev = [{ name: 'a', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }];
  const next = [{ name: 'a', bell: { unseen_count: 1, last_fired_at: 100, seen_at: null } }];
  assert.deepStrictEqual(detectBellTransitions(prev, next), ['a']);
});
test('detectBellTransitions: no change returns empty array', () => {
  const sessions = [{ name: 'a', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }];
  assert.deepStrictEqual(detectBellTransitions(sessions, sessions), []);
});
test('detectBellTransitions: new session with bell fires', () => {
  const prev = [];
  const next = [{ name: 'b', bell: { unseen_count: 1, last_fired_at: 200, seen_at: null } }];
  assert.deepStrictEqual(detectBellTransitions(prev, next), ['b']);
});
test('detectBellTransitions: count increase fires, count decrease does not', () => {
  const prev = [{ name: 'a', bell: { unseen_count: 2, last_fired_at: 100, seen_at: null } }];
  const next_increase = [{ name: 'a', bell: { unseen_count: 3, last_fired_at: 200, seen_at: null } }];
  const next_same     = [{ name: 'a', bell: { unseen_count: 2, last_fired_at: 100, seen_at: null } }];
  assert.deepStrictEqual(detectBellTransitions(prev, next_increase), ['a']);
  assert.deepStrictEqual(detectBellTransitions(prev, next_same), []);
});

// ── Task 3: generateDeviceId ────────────────────────────────────────────────
test('generateDeviceId: returns a string matching d-[a-z0-9]+', () => {
  assert.match(generateDeviceId(), /^d-[a-z0-9]+$/);
});
test('generateDeviceId: returns unique IDs on repeated calls', () => {
  const ids = new Set(Array.from({ length: 10 }, generateDeviceId));
  assert.ok(ids.size > 1, 'generateDeviceId should return different values');
});

// ── Task 3: buildHeartbeatPayload ───────────────────────────────────────────
test('buildHeartbeatPayload: returns correct shape', () => {
  const p = buildHeartbeatPayload('d-abc', 'work', 'fullscreen', 123.45);
  assert.strictEqual(p.device_id, 'd-abc');
  assert.strictEqual(p.viewing_session, 'work');
  assert.strictEqual(p.view_mode, 'fullscreen');
  assert.strictEqual(p.last_interaction_at, 123.45);
  assert.ok(typeof p.label === 'string', 'label must be a string');
  assert.ok(p.label.length > 0, 'label must not be empty');
});
test('buildHeartbeatPayload: viewing_session can be null', () => {
  const p = buildHeartbeatPayload('d-xyz', null, 'grid', 0);
  assert.strictEqual(p.viewing_session, null);
  assert.strictEqual(p.view_mode, 'grid');
});

Step 2: Run tests — expect FAIL

node --test frontend/tests/test_app.mjs

Expected: the 11 existing tests still pass; the 14 new ones fail because the stubs return wrong values.


Step 3: Replace the remaining 4 stubs in frontend/app.js

Replace the four stub bodies (above the export block):

function filterByQuery(sessions, query) {
  if (!query) return sessions;
  const q = query.toLowerCase();
  return sessions.filter(s => s.name.toLowerCase().includes(q));
}

function detectBellTransitions(prevSessions, nextSessions) {
  const prevMap = new Map(
    prevSessions.map(s => [s.name, s.bell ? s.bell.unseen_count : 0])
  );
  return nextSessions
    .filter(s => {
      const b = s.bell;
      if (!b || b.unseen_count === 0) return false;
      const prevCount = prevMap.has(s.name) ? prevMap.get(s.name) : 0;
      return b.unseen_count > prevCount;
    })
    .map(s => s.name);
}

function generateDeviceId() {
  const rand = Math.random().toString(36).slice(2, 10);
  return 'd-' + rand;
}

function buildHeartbeatPayload(deviceId, viewingSession, viewMode, lastInteractionAt) {
  var label = (typeof navigator !== 'undefined' && navigator.userAgent)
    ? navigator.userAgent.slice(0, 50)
    : 'unknown';
  return {
    device_id: deviceId,
    label: label,
    viewing_session: viewingSession,
    view_mode: viewMode,
    last_interaction_at: lastInteractionAt,
  };
}

Step 4: Run tests — expect PASS

node --test frontend/tests/test_app.mjs

Expected:

# tests 25
# pass 25
# fail 0

Step 5: Commit

git add frontend/app.js frontend/tests/test_app.mjs
git commit -m "feat: add filterByQuery, detectBellTransitions, generateDeviceId, buildHeartbeatPayload"

Task 4: App initialization — device ID, fetch wrapper, interaction tracking, DOMContentLoaded

Files:

  • Modify: frontend/app.js (add state variables, helpers, DOMContentLoaded handler)

No unit tests — requires DOM. Manual verification below.


Step 1: Add state variables and runtime code to frontend/app.js

Add this block below all the pure functions and above the conditional export block:

// ── Constants ───────────────────────────────────────────────────────────────
var POLL_MS = 2000;
var HEARTBEAT_MS = 5000;
var MOBILE_THRESHOLD = 600;

// ── Runtime state ───────────────────────────────────────────────────────────
var _deviceId = '';
var _currentSessions = [];
var _viewingSession = null;   // name of session open in expanded view, or null
var _viewMode = 'grid';       // 'grid' | 'fullscreen'
var _lastInteractionAt = Date.now() / 1000;
var _pollingTimer = null;
var _heartbeatTimer = null;
var _notificationPermission = 'default';

// ── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function on(el, ev, fn) { if (el) el.addEventListener(ev, fn); }

// ── isMobile ─────────────────────────────────────────────────────────────────
function isMobile() { return window.innerWidth < MOBILE_THRESHOLD; }

// ── API fetch wrapper ────────────────────────────────────────────────────────
function api(method, path, body) {
  var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
  if (body) opts.body = JSON.stringify(body);
  return fetch(path, opts).then(function(res) {
    if (!res.ok) throw new Error('API ' + method + ' ' + path + ' failed: ' + res.status);
    return res.json();
  });
}

// ── Device ID ────────────────────────────────────────────────────────────────
function initDeviceId() {
  _deviceId = window.localStorage.getItem('tmux-web-device-id');
  if (!_deviceId) {
    _deviceId = generateDeviceId();
    window.localStorage.setItem('tmux-web-device-id', _deviceId);
  }
}

// ── Interaction tracking ──────────────────────────────────────────────────────
function trackInteraction() {
  _lastInteractionAt = Date.now() / 1000;
}

// ── DOMContentLoaded ──────────────────────────────────────────────────────────
// restoreState, startPolling, startHeartbeat, requestNotificationPermission,
// and bindStaticEventListeners are defined in Tasks 5, 6, 8, 9, 11.
// JavaScript hoists function declarations — calling them here is safe.
document.addEventListener('DOMContentLoaded', function() {
  initDeviceId();
  document.addEventListener('keydown', trackInteraction);
  document.addEventListener('click', trackInteraction);
  document.addEventListener('touchstart', trackInteraction, { passive: true });

  restoreState().then(function() {
    startPolling();
    startHeartbeat();
    requestNotificationPermission();
    bindStaticEventListeners();
  });
});

Step 2: Run the Node.js unit tests — verify they still pass

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0 — the new runtime code doesn't break any pure-function tests because the document.addEventListener call runs only in the browser (in Node.js, document.addEventListener is a no-op via our global stub and DOMContentLoaded never fires).


Step 3: Manual browser verification

Start the coordinator:

cd /home/bkrabach/dev/web-tmux
uvicorn coordinator.main:app --reload --port 8000

Open http://localhost:8000 in a browser. Check:

  1. DevTools Console — no errors on load
  2. DevTools Application → Local Storage — key tmux-web-device-id exists with a value like d-a3f9b2c1
  3. Console — no "restoreState is not defined" or "startPolling is not defined" errors yet (you will see "TypeError: restoreState is not a function" — that is EXPECTED; it will be fixed in Task 5)

Note: Until Tasks 5-11 are complete, the browser console will show TypeError: restoreState is not a function. That is correct — those functions don't exist yet. The plan adds them in order.


Step 4: Commit

git add frontend/app.js
git commit -m "feat: add app initialization — device_id, fetch wrapper, interaction tracking"

Task 5: State restoration — GET /api/state on load

Files:

  • Modify: frontend/app.js (add restoreState)

No unit tests — requires fetch. Manual verification below.


Step 1: Add restoreState to frontend/app.js

Add this block above the DOMContentLoaded listener (keep it below the trackInteraction function, and above the export block):

// ── State restoration ─────────────────────────────────────────────────────────
// Called once on page load. If the coordinator knows about an active session
// (a previous browser tab connected), re-open it without calling /connect again
// (ttyd is already running). skipConnect: true skips the POST /connect call.
function restoreState() {
  return api('GET', '/api/state').then(function(state) {
    if (state.active_session) {
      return openSession(state.active_session, { skipConnect: true });
    }
  }).catch(function(err) {
    console.warn('restoreState: failed to fetch state', err);
  });
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Start the coordinator and open the browser. Now:

  1. Console — restoreState is not a function error is gone
  2. Network tab — GET /api/state request fires on page load
  3. If active_session is null (no prior session): stays on grid view
  4. If active_session is set: should attempt openSession (which still fails gracefully because openSession doesn't exist yet — that is expected until Task 10)

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add state restoration on page load"

Task 6: Session polling — GET /api/sessions every 2s

Files:

  • Modify: frontend/app.js (add startPolling, pollSessions, setConnectionStatus)

No unit tests — requires fetch and DOM. Manual verification below.


Step 1: Add polling functions to frontend/app.js

Add this block below restoreState (above DOMContentLoaded, above export block):

// ── Connection status ─────────────────────────────────────────────────────────
function setConnectionStatus(level) {
  // level: 'ok' | 'warn' | 'err'
  var el = $('connection-status');
  if (!el) return;
  el.className = 'connection-status connection-status--' + level;
  if (level === 'ok')   el.textContent = '●';
  if (level === 'warn') el.textContent = '◌ slow';
  if (level === 'err')  el.textContent = '✕ offline';
}

// ── Session polling ───────────────────────────────────────────────────────────
var _pollFailCount = 0;

function startPolling() {
  if (_pollingTimer) return;
  pollSessions(); // immediate first fetch
  _pollingTimer = setInterval(pollSessions, POLL_MS);
}

function pollSessions() {
  api('GET', '/api/sessions').then(function(sessions) {
    var prev = _currentSessions;
    _currentSessions = sessions;
    _pollFailCount = 0;
    setConnectionStatus('ok');
    renderGrid(sessions);
    handleBellTransitions(prev, sessions);
  }).catch(function(err) {
    _pollFailCount++;
    setConnectionStatus(_pollFailCount > 2 ? 'err' : 'warn');
    console.warn('pollSessions failed', err);
  });
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. Check:

  1. Network tab — GET /api/sessions fires on page load and every ~2 seconds
  2. #connection-status element in header shows (green dot) when requests succeed
  3. Console — no errors (ignore "renderGrid is not defined" — that is Task 7)

If you stop the coordinator and wait 3 polls:

  • Status changes to ◌ slow after first failure, then ✕ offline after the third

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add session polling loop — GET /api/sessions every 2s"

Task 7: renderGrid — tile HTML for desktop grid and mobile list

Files:

  • Modify: frontend/app.js (add renderGrid, buildTileHTML, escapeHtml)

No unit tests — requires DOM. Manual verification below.


Step 1: Add grid rendering functions to frontend/app.js

Add this block below the polling functions (above DOMContentLoaded, above export block):

// ── Grid rendering ────────────────────────────────────────────────────────────
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

function buildTileHTML(session, index, mobile) {
  var b = session.bell || {};
  var hasBell = b.unseen_count > 0 && (b.seen_at === null || b.last_fired_at > b.seen_at);
  var snap = (session.snapshot || '').trimEnd();
  // Show last 20 lines of snapshot
  var lines = snap ? snap.split('\n').slice(-20).join('\n') : '(no output)';
  var priority = sessionPriority(session);

  var bellClass = hasBell ? ' session-tile--bell' : '';
  var tierClass = mobile ? ' session-tile--tier-' + priority : '';
  var bellCount = b.unseen_count > 9 ? '9+' : b.unseen_count;
  var bellDot = hasBell
    ? '<span class="tile-bell" aria-hidden="true"></span>' +
      '<span class="tile-bell-count">' + bellCount + '</span>'
    : '';

  return '<article class="session-tile' + bellClass + tierClass + '"' +
    ' data-session="' + escapeHtml(session.name) + '"' +
    ' tabindex="0"' +
    ' role="listitem"' +
    ' aria-label="Session ' + escapeHtml(session.name) + (hasBell ? ', has activity' : '') + '">' +
    '<div class="tile-header">' +
      '<span class="tile-name">' + escapeHtml(session.name) + '</span>' +
      '<span class="tile-meta">' + bellDot +
        '<span class="tile-time">' + formatTimestamp(b.last_fired_at || null) + '</span>' +
      '</span>' +
    '</div>' +
    '<div class="tile-body">' +
      '<pre class="tile-pre">' + escapeHtml(lines) + '</pre>' +
    '</div>' +
  '</article>';
}

function renderGrid(sessions) {
  var grid = $('session-grid');
  var emptyState = $('empty-state');
  if (!grid) return;

  if (!sessions || sessions.length === 0) {
    grid.innerHTML = '';
    if (emptyState) emptyState.classList.remove('hidden');
    return;
  }
  if (emptyState) emptyState.classList.add('hidden');

  var mobile = isMobile();
  var ordered = mobile ? sortByPriority(sessions) : sessions;

  grid.innerHTML = ordered.map(function(s, i) {
    return buildTileHTML(s, i + 1, mobile);
  }).join('');

  // Bind click handlers on newly-inserted tiles
  var tiles = grid.querySelectorAll('.session-tile');
  for (var i = 0; i < tiles.length; i++) {
    (function(tile) {
      tile.addEventListener('click', function() { openSession(tile.dataset.session); });
      tile.addEventListener('keydown', function(e) {
        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSession(tile.dataset.session); }
      });
    })(tiles[i]);
  }

  // If in expanded view, update the pill bell badge for "other sessions"
  if (_viewMode === 'fullscreen') updatePillBell();
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. You should now see:

  1. Session tiles rendered in the grid (one per tmux session)
  2. Each tile shows session name, last activity time, and terminal snapshot text
  3. Run printf '\a' in a tmux session — within 2 seconds the tile gets amber border glow (.session-tile--bell class)
  4. On mobile-size viewport (< 600px wide), tiles reorder: bell first, then active, then idle

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add renderGrid — tile HTML for desktop grid and mobile list"

Task 8: Bell indicators + Browser Notifications

Files:

  • Modify: frontend/app.js (add requestNotificationPermission, handleBellTransitions)

No unit tests — requires Notification API and DOM. Manual verification below.


Step 1: Add bell functions to frontend/app.js

Add this block below renderGrid (above DOMContentLoaded, above export block):

// ── Browser Notifications ─────────────────────────────────────────────────────
function requestNotificationPermission() {
  if (!('Notification' in window)) return;
  if (Notification.permission === 'granted') {
    _notificationPermission = 'granted';
    return;
  }
  if (Notification.permission === 'default') {
    Notification.requestPermission().then(function(perm) {
      _notificationPermission = perm;
    });
  } else {
    _notificationPermission = Notification.permission; // 'denied'
  }
}

function handleBellTransitions(prevSessions, nextSessions) {
  var transitions = detectBellTransitions(prevSessions, nextSessions);
  if (transitions.length === 0) return;
  transitions.forEach(function(name) {
    if (_notificationPermission === 'granted' && document.hidden) {
      // `tag` deduplicates: one notification per session, not per bell event.
      new Notification('Activity in: ' + name, {
        body: 'tmux session needs attention',
        tag: 'tmux-bell-' + name,
      });
    }
  });
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

  1. Open http://localhost:8000 — browser should prompt for notification permission. Allow it.
  2. Open another browser tab (so the dashboard tab is backgrounded: document.hidden === true).
  3. In a tmux session: printf '\a'
  4. Within 2 seconds: an OS notification appears: "Activity in: <session-name>"
  5. Switch back to dashboard tab — tile has amber glow

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add bell indicators and browser notification on bell transition"

Task 9: Heartbeat sender — POST /api/heartbeat every 5s

Files:

  • Modify: frontend/app.js (add startHeartbeat, sendHeartbeat)

No unit tests — requires fetch. Manual verification below.


Step 1: Add heartbeat functions to frontend/app.js

Add this block below handleBellTransitions (above DOMContentLoaded, above export block):

// ── Heartbeat ─────────────────────────────────────────────────────────────────
function startHeartbeat() {
  if (_heartbeatTimer) return;
  sendHeartbeat(); // immediate first send
  _heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
}

function sendHeartbeat() {
  var payload = buildHeartbeatPayload(
    _deviceId,
    _viewingSession,
    _viewMode,
    _lastInteractionAt
  );
  api('POST', '/api/heartbeat', payload).catch(function(err) {
    console.warn('heartbeat failed', err);
  });
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. Check:

  1. Network tab — POST /api/heartbeat fires on page load and every ~5 seconds
  2. Request body includes: device_id, label, viewing_session (null when on grid), view_mode ("grid"), last_interaction_at (unix timestamp)
  3. Response: {"device_id": "d-...", "status": "ok"}
  4. GET /api/state → check devices key — your device should appear

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add heartbeat sender — POST /api/heartbeat every 5s"

Task 10: openSession/closeSession — zoom transition and view switching

Files:

  • Modify: frontend/app.js (add openSession, closeSession, showToast)

No unit tests — requires DOM. Manual verification below.


Step 1: Add session transition functions to frontend/app.js

Add this block below sendHeartbeat (above DOMContentLoaded, above export block):

// ── Toast notification ────────────────────────────────────────────────────────
function showToast(msg) {
  var toast = $('toast');
  if (!toast) return;
  toast.textContent = msg;
  toast.classList.remove('hidden');
  setTimeout(function() { toast.classList.add('hidden'); }, 3000);
}

// ── updatePillBell ────────────────────────────────────────────────────────────
// Updates the bell badge on the floating session pill.
// Called from renderGrid (when polling) and openSession.
function updatePillBell() {
  var pillBell = $('session-pill-bell');
  if (!pillBell) return;
  var othersWithBell = _currentSessions.filter(function(s) {
    return s.name !== _viewingSession && s.bell && s.bell.unseen_count > 0;
  });
  if (othersWithBell.length > 0) pillBell.classList.remove('hidden');
  else pillBell.classList.add('hidden');
}

// ── openSession ───────────────────────────────────────────────────────────────
// Opens a session in the expanded terminal view.
// opts.skipConnect = true: don't POST /connect (ttyd already running after restore).
function openSession(name, opts) {
  opts = opts || {};
  var skipConnect = opts.skipConnect === true;

  // Update app state
  _viewingSession = name;
  _viewMode = 'fullscreen';

  var nameEl = $('expanded-session-name');
  if (nameEl) nameEl.textContent = name;

  // Find tile for zoom origin (may not exist if view was already expanded)
  var tile = document.querySelector('[data-session="' + CSS.escape(name) + '"]');
  var grid = $('session-grid');

  if (tile && grid) {
    var rect = tile.getBoundingClientRect();
    // Pin tile at its current position, then animate to fullscreen
    tile.style.cssText = 'position:fixed;top:' + rect.top + 'px;left:' + rect.left +
      'px;width:' + rect.width + 'px;height:' + rect.height +
      'px;z-index:50;transition:all 250ms ease-in-out;';
    tile.classList.add('session-tile--expanding');
    grid.classList.add('session-grid--dimming');

    // Force reflow so the browser registers the starting position
    void tile.getBoundingClientRect();

    // Animate to full viewport
    requestAnimationFrame(function() {
      tile.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;' +
        'z-index:50;transition:all 250ms ease-in-out;border-radius:0;';
    });
  }

  // Wait for animation, then switch views
  return new Promise(function(resolve) {
    setTimeout(function() {
      var viewOverview = $('view-overview');
      var viewExpanded = $('view-expanded');
      if (viewOverview) { viewOverview.classList.remove('view--active'); viewOverview.classList.add('hidden'); }
      if (viewExpanded) { viewExpanded.classList.remove('hidden'); viewExpanded.classList.add('view--active'); }

      // Show mobile session pill
      if (isMobile()) {
        var pill = $('session-pill');
        var pillLabel = $('session-pill-label');
        if (pill) pill.classList.remove('hidden');
        if (pillLabel) pillLabel.textContent = name;
        updatePillBell();
      }

      // Connect to session (spawns ttyd)
      if (skipConnect) {
        if (window._openTerminal) window._openTerminal(name);
        resolve();
        return;
      }

      api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect')
        .then(function() {
          if (window._openTerminal) window._openTerminal(name);
          resolve();
        })
        .catch(function(err) {
          console.error('openSession: connect failed', err);
          showToast("Couldn't connect to session '" + name + "'");
          closeSession().then(resolve);
        });
    }, 260);
  });
}

// ── closeSession ──────────────────────────────────────────────────────────────
function closeSession() {
  _viewMode = 'grid';
  _viewingSession = null;

  // Close xterm.js terminal
  if (window._closeTerminal) window._closeTerminal();

  // Kill ttyd connection
  api('DELETE', '/api/sessions/current').catch(function() {});

  // Switch back to overview
  var viewExpanded = $('view-expanded');
  var viewOverview = $('view-overview');
  if (viewExpanded) { viewExpanded.classList.remove('view--active'); viewExpanded.classList.add('hidden'); }
  if (viewOverview) { viewOverview.classList.remove('hidden'); viewOverview.classList.add('view--active'); }

  // Hide mobile pill
  var pill = $('session-pill');
  if (pill) pill.classList.add('hidden');

  return Promise.resolve();
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. Check:

  1. Click a session tile — tile animates (zoom expand) to fill the viewport
  2. Expanded header shows session name
  3. Network — POST /api/sessions/<name>/connect fires, returns {"active_session": ..., "ttyd_port": 7682}
  4. Terminal area shows (empty — terminal.js isn't wired yet, added in Tasks 12-13)
  5. Click the back button — returns to grid view
  6. Grid tiles reappear
  7. Network — DELETE /api/sessions/current fires

If ttyd is not running, a toast appears: "Couldn't connect to session '...'" and the view returns to grid.


Step 4: Commit

git add frontend/app.js
git commit -m "feat: add openSession/closeSession — zoom transition and view switching"

Task 11: Command palette — Ctrl+K, keyboard nav, filter, Escape, G-to-grid

Files:

  • Modify: frontend/app.js (add bindStaticEventListeners, all palette functions)

No unit tests — requires DOM. Manual verification below.


Step 1: Add command palette functions to frontend/app.js

Add this block below closeSession (above DOMContentLoaded, above export block):

// ── Command palette ────────────────────────────────────────────────────────────
var _paletteSelectedIndex = 0;
var _paletteFilteredSessions = [];

function openPalette() {
  var palette = $('command-palette');
  if (!palette) return;
  palette.classList.remove('hidden');

  _paletteFilteredSessions = _currentSessions.slice();
  renderPaletteList(_paletteFilteredSessions);
  _paletteSelectedIndex = 0;
  highlightPaletteItem(0);

  var input = $('palette-input');
  if (input) {
    input.value = '';
    input.focus();
    input.addEventListener('input', onPaletteInput);
  }
}

function closePalette() {
  var palette = $('command-palette');
  if (palette) palette.classList.add('hidden');
  var input = $('palette-input');
  if (input) input.removeEventListener('input', onPaletteInput);
}

function onPaletteInput(e) {
  _paletteFilteredSessions = filterByQuery(_currentSessions, e.target.value);
  renderPaletteList(_paletteFilteredSessions);
  _paletteSelectedIndex = 0;
  highlightPaletteItem(0);
}

function renderPaletteList(sessions) {
  var list = $('palette-list');
  if (!list) return;
  var items = sessions.slice(0, 9);
  list.innerHTML = items.map(function(s, i) {
    var hasBell = s.bell && s.bell.unseen_count > 0;
    return '<li class="palette-item" role="option"' +
      ' data-session="' + escapeHtml(s.name) + '" tabindex="-1">' +
      '<span class="palette-item__index">' + (i + 1) + '</span>' +
      '<span class="palette-item__name">' + escapeHtml(s.name) + '</span>' +
      (hasBell ? '<span class="palette-item__bell" aria-hidden="true">🔔</span>' : '') +
      '<span class="palette-item__time">' + formatTimestamp(s.bell ? s.bell.last_fired_at : null) + '</span>' +
    '</li>';
  }).join('');

  list.querySelectorAll('.palette-item').forEach(function(item) {
    item.addEventListener('click', function() {
      var name = item.dataset.session;
      closePalette();
      openSession(name);
    });
  });
}

function highlightPaletteItem(index) {
  var items = $('palette-list') ? $('palette-list').querySelectorAll('.palette-item') : [];
  for (var i = 0; i < items.length; i++) {
    items[i].classList.toggle('palette-item--selected', i === index);
  }
}

function handlePaletteKeydown(e) {
  var list = $('palette-list');
  var items = list ? Array.prototype.slice.call(list.querySelectorAll('.palette-item')) : [];
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      _paletteSelectedIndex = Math.min(_paletteSelectedIndex + 1, items.length - 1);
      highlightPaletteItem(_paletteSelectedIndex);
      break;
    case 'ArrowUp':
      e.preventDefault();
      _paletteSelectedIndex = Math.max(_paletteSelectedIndex - 1, 0);
      highlightPaletteItem(_paletteSelectedIndex);
      break;
    case 'Enter':
      e.preventDefault();
      var selected = items[_paletteSelectedIndex];
      if (selected) { closePalette(); openSession(selected.dataset.session); }
      break;
    case 'Escape':
      e.preventDefault();
      closePalette();
      break;
    case 'g':
    case 'G':
      e.preventDefault();
      closePalette();
      closeSession();
      break;
    default:
      // Number keys 1-9 jump directly to session
      if (/^[1-9]$/.test(e.key)) {
        e.preventDefault();
        var item = items[parseInt(e.key, 10) - 1];
        if (item) { closePalette(); openSession(item.dataset.session); }
      }
  }
}

// ── Global keyboard handler ────────────────────────────────────────────────────
function handleGlobalKeydown(e) {
  var palette = $('command-palette');
  var paletteOpen = palette && !palette.classList.contains('hidden');

  if (paletteOpen) {
    handlePaletteKeydown(e);
    return;
  }

  if (_viewMode === 'fullscreen') {
    // Backtick ` or Ctrl+K → open palette
    if (e.key === '`' || (e.ctrlKey && e.key === 'k')) {
      e.preventDefault();
      openPalette();
      return;
    }
    // Escape → back to grid (when palette is closed)
    if (e.key === 'Escape') {
      closeSession();
      return;
    }
  }
}

// ── Static event listener binding ─────────────────────────────────────────────
// Called once from DOMContentLoaded after all functions are defined.
function bindStaticEventListeners() {
  // Back button (← in expanded header)
  on($('back-btn'), 'click', function() { closeSession(); });

  // ⌘K button in expanded header
  on($('palette-trigger'), 'click', function() { openPalette(); });

  // Palette backdrop — click outside to close
  on($('palette-backdrop'), 'click', function() { closePalette(); });

  // Global keyboard shortcuts
  document.addEventListener('keydown', handleGlobalKeydown);

  // Session pill → open bottom sheet (Task 15 adds body of openBottomSheet)
  on($('session-pill'), 'click', function() { openBottomSheet(); });

  // Bottom sheet backdrop
  on($('sheet-backdrop'), 'click', function() { closeBottomSheet(); });
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. Open a session by clicking a tile. Then:

  1. Press Ctrl+K or ` — command palette dialog opens
  2. Type a session name fragment — list filters in real time
  3. Arrow Down/Up — highlighted item moves
  4. Press 2 — second session opens directly
  5. Press Escape — palette closes, stays in expanded view
  6. Press Ctrl+K again to reopen, press G — returns to grid view
  7. In expanded view, press Escape (palette closed) — returns to grid view

Step 4: Commit

git add frontend/app.js
git commit -m "feat: add command palette — Ctrl+K, keyboard navigation, filter"

Task 12: terminal.js — xterm.js Terminal + FitAddon initialization

Files:

  • Modify: frontend/terminal.js (replace stub entirely with xterm.js init)

No unit tests — requires xterm.js CDN (browser-only). Manual verification below.


Step 1: Replace frontend/terminal.js with xterm.js init code

Replace the entire file with:

/* terminal.js — xterm.js terminal management for tmux-web
 *
 * Loaded after app.js (see index.html script order). Exposes openTerminal and
 * closeTerminal to app.js via window._ to avoid circular dependencies.
 *
 * Dependencies: xterm@5.3.0 + xterm-addon-fit@0.8.0, both loaded from CDN
 * in index.html before this script runs. They are available as window.Terminal
 * and window.FitAddon.FitAddon.
 */

var SCROLLBACK_MOBILE = 500;
var SCROLLBACK_DESKTOP = 5000;
var MOBILE_THRESHOLD = 600;

var _term = null;
var _fitAddon = null;
var _ws = null;
var _reconnectTimer = null;
var _currentSession = null;
var _vpHandler = null;

function isMobileTerm() { return window.innerWidth < MOBILE_THRESHOLD; }

// ── createTerminal ────────────────────────────────────────────────────────────
// Disposes any existing terminal, creates a fresh xterm.js Terminal instance.
function createTerminal() {
  if (_term) {
    try { _term.dispose(); } catch (e) {}
    _term = null;
    _fitAddon = null;
  }

  var Terminal = window.Terminal;
  var FitAddonClass = window.FitAddon && window.FitAddon.FitAddon;

  if (!Terminal) {
    console.error('terminal.js: xterm.js not loaded from CDN');
    return;
  }

  _term = new Terminal({
    cursorBlink: true,
    fontSize: isMobileTerm() ? 12 : 14,
    fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
    theme: {
      background: '#000000',
      foreground: '#c9d1d9',
      cursor: '#58a6ff',
      selectionBackground: 'rgba(88, 166, 255, 0.3)',
    },
    scrollback: isMobileTerm() ? SCROLLBACK_MOBILE : SCROLLBACK_DESKTOP,
    allowProposedApi: true,
  });

  if (FitAddonClass) {
    _fitAddon = new FitAddonClass();
    _term.loadAddon(_fitAddon);
  }
}

// ── openTerminal ──────────────────────────────────────────────────────────────
// Called by app.js openSession() after the expanded view is visible.
function openTerminal(sessionName) {
  _currentSession = sessionName;

  var container = document.getElementById('terminal-container');
  if (!container) {
    console.error('terminal.js: #terminal-container not found');
    return;
  }

  createTerminal();
  if (!_term) return;

  _term.open(container);
  if (_fitAddon) {
    try { _fitAddon.fit(); } catch (e) {}
  }

  connectWebSocket(sessionName);
  initVisualViewport();
}

// ── closeTerminal ─────────────────────────────────────────────────────────────
// Called by app.js closeSession().
function closeTerminal() {
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
  if (_ws) { try { _ws.close(); } catch (e) {} _ws = null; }
  if (_term) { try { _term.dispose(); } catch (e) {} _term = null; }
  if (_vpHandler && window.visualViewport) {
    window.visualViewport.removeEventListener('resize', _vpHandler);
    _vpHandler = null;
  }
  _currentSession = null;

  // Clear the container so stale canvas doesn't flash on next open
  var container = document.getElementById('terminal-container');
  if (container) container.innerHTML = '';
}

// ── connectWebSocket and initVisualViewport are defined in Tasks 13 and 14 ──
// Forward stubs so closeTerminal above compiles without reference errors.
function connectWebSocket(sessionName) {}
function initVisualViewport() {}

// ── Expose to app.js via window ───────────────────────────────────────────────
// app.js checks window._openTerminal and window._closeTerminal before calling.
window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0 (terminal.js is not loaded by Node.js tests)


Step 3: Manual browser verification

Open http://localhost:8000. Click a session tile. In the expanded view:

  1. DevTools Console — no xterm.js not loaded error
  2. #terminal-container has a canvas element inside it (xterm.js rendered)
  3. Terminal cursor is visible and blinking
  4. The terminal is sized to fill the container (FitAddon working)
  5. Note: typing produces no output yet — WebSocket is stubbed until Task 13

Step 4: Commit

git add frontend/terminal.js
git commit -m "feat: add terminal.js — xterm.js Terminal and FitAddon initialization"

Task 13: WebSocket connection to ttyd via /terminal/

Files:

  • Modify: frontend/terminal.js (replace connectWebSocket stub with real implementation)

No unit tests — requires WebSocket + browser. Manual verification below.


Step 1: Replace the connectWebSocket stub in frontend/terminal.js

Find this line in terminal.js:

function connectWebSocket(sessionName) {}

Replace it with:

// ── WebSocket to ttyd ─────────────────────────────────────────────────────────
// ttyd runs on port 7682; Caddy proxies /terminal/ → ws://localhost:7682
function connectWebSocket(sessionName) {
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  var url = proto + '//' + location.host + '/terminal/';
  var reconnectOverlay = document.getElementById('reconnect-overlay');

  function connect() {
    if (!_currentSession) return; // session was closed before connect resolved
    _ws = new WebSocket(url);
    _ws.binaryType = 'arraybuffer';

    _ws.addEventListener('open', function() {
      if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
    });

    _ws.addEventListener('message', function(e) {
      if (!_term) return;
      if (e.data instanceof ArrayBuffer) {
        _term.write(new Uint8Array(e.data));
      } else {
        _term.write(e.data);
      }
    });

    _ws.addEventListener('close', function() {
      if (!_currentSession) return; // intentional close via closeTerminal()
      if (reconnectOverlay) reconnectOverlay.classList.remove('hidden');
      _reconnectTimer = setTimeout(connect, 2000);
    });

    _ws.addEventListener('error', function(e) {
      console.warn('terminal.js: WebSocket error', e);
    });

    // Forward terminal keystrokes → ttyd
    if (_term) {
      _term.onData(function(data) {
        if (_ws && _ws.readyState === WebSocket.OPEN) {
          _ws.send(data);
        }
      });
    }

    // Notify ttyd of terminal size changes
    if (_term) {
      _term.onResize(function(size) {
        if (_ws && _ws.readyState === WebSocket.OPEN) {
          // ttyd accepts resize as a JSON message
          _ws.send(JSON.stringify({ columns: size.cols, rows: size.rows }));
        }
      });
    }
  }

  connect();
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

Open http://localhost:8000. Click a session tile. In the expanded view:

  1. Terminal renders and shows the tmux session output
  2. Type commands — they execute in the real tmux session
  3. Close browser tab (or manually close the WebSocket via DevTools Network → WS → X)
    • "Reconnecting…" overlay appears on #reconnect-overlay
    • After 2 seconds, it automatically reconnects and overlay disappears
  4. Navigate back to grid (← button), then click the same session again
    • Terminal re-opens cleanly with no stale canvas

Step 4: Commit

git add frontend/terminal.js
git commit -m "feat: add WebSocket connection to ttyd via /terminal/"

Task 14: visualViewport mobile keyboard resize

Files:

  • Modify: frontend/terminal.js (replace initVisualViewport stub with real implementation)

No unit tests — requires browser + mobile emulation. Manual verification below.


Step 1: Replace the initVisualViewport stub in frontend/terminal.js

Find this line in terminal.js:

function initVisualViewport() {}

Replace it with:

// ── visualViewport keyboard resize ────────────────────────────────────────────
// On mobile, when the software keyboard appears, window.innerHeight shrinks but
// the layout doesn't reflow automatically. We use window.visualViewport to detect
// the actual visible height and resize #terminal-container to fit above the keyboard.
function initVisualViewport() {
  if (!window.visualViewport) return;

  // Remove any previous listener to avoid stacking on reconnect
  if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);

  _vpHandler = function() {
    if (!_term || !_fitAddon) return;
    var container = document.getElementById('terminal-container');
    if (!container) return;

    var vvh = window.visualViewport.height;
    var headerHeight = 44; // matches CSS --header-height var
    var termHeight = Math.max(100, vvh - headerHeight);
    container.style.height = termHeight + 'px';

    try { _fitAddon.fit(); } catch (e) {}
  };

  window.visualViewport.addEventListener('resize', _vpHandler);

  // Fire immediately to handle the case where the keyboard was already open
  _vpHandler();
}

Step 2: Run Node.js tests — verify still passing

node --test frontend/tests/test_app.mjs

Expected: # pass 25 # fail 0


Step 3: Manual browser verification

  1. Open http://localhost:8000 in Chrome DevTools → Toggle device toolbar (set to iPhone-size, < 600px wide)
  2. Click a session tile to open expanded view
  3. Tap in the terminal area — software keyboard appears
  4. Verify terminal is still visible above the keyboard (container height adjusted)
  5. Type a command — it executes

Without this fix, the terminal would be partially hidden behind the keyboard on mobile.


Step 4: Commit

git add frontend/terminal.js
git commit -m "feat: add visualViewport keyboard resize for mobile terminal"

Task 15: Mobile bottom sheet + session pill

Files:

  • Modify: frontend/app.js (add openBottomSheet, closeBottomSheet, renderSheetList)

No unit tests — requires DOM. Manual verification below.


Step 1: Add bottom sheet functions to frontend/app.js

Add this block below bindStaticEventListeners (above the conditional export block):

// ── Mobile bottom sheet ────────────────────────────────────────────────────────
// Displayed when the user taps the session pill while in expanded view.
// Shows all sessions ordered by priority. Tapping switches to that session.

function openBottomSheet() {
  var sheet = $('bottom-sheet');
  if (!sheet) return;
  renderSheetList();
  sheet.classList.remove('hidden');
}

function closeBottomSheet() {
  var sheet = $('bottom-sheet');
  if (sheet) sheet.classList.add('hidden');
}

function renderSheetList() {
  var list = $('sheet-list');
  if (!list) return;
  var sorted = sortByPriority(_currentSessions);
  list.innerHTML = sorted.map(function(s) {
    var hasBell = s.bell && s.bell.unseen_count > 0;
    var isActive = s.name === _viewingSession;
    return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
      ' data-session="' + escapeHtml(s.name) + '" role="option">' +
      '<span class="sheet-item__name">' + escapeHtml(s.name) + '</span>' +
      (hasBell ? '<span class="sheet-item__bell" aria-hidden="true">🔔</span>' : '') +
      '<span class="sheet-item__time">' + formatTimestamp(s.bell ? s.bell.last_fired_at : null) + '</span>' +
    '</li>';
  }).join('');

  list.querySelectorAll('.sheet-item').forEach(function(item) {
    item.addEventListener('click', function() {
      var name = item.dataset.session;
      closeBottomSheet();
      if (name !== _viewingSession) {
        // Close current session first, then open new one
        closeSession().then(function() { openSession(name); });
      }
    });
  });
}

Step 2: Run all Node.js tests — final check

node --test frontend/tests/test_app.mjs

Expected:

# tests 25
# pass 25
# fail 0

Step 3: Run all Python tests — verify nothing broke

cd /home/bkrabach/dev/web-tmux
python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q

Expected: 118 passed, 0 failed


Step 4: Manual browser verification (mobile)

  1. Open http://localhost:8000 in DevTools mobile mode (< 600px wide)
  2. Click a session tile — expanded view opens, floating pill appears at bottom with session name
  3. Tap the pill — bottom sheet slides up showing all sessions, sorted (bell first)
  4. Active session has .sheet-item--active class (visually distinguished)
  5. Tap a different session — sheet closes, new session opens

On desktop (> 600px wide):

  • Pill is hidden (.hidden class stays on #session-pill)
  • Bottom sheet does not appear

Step 5: Commit

git add frontend/app.js
git commit -m "feat: add mobile bottom sheet and session pill"

Final Verification Checklist

After all 15 tasks are complete, run this full verification sequence:

Check 1: Node.js unit tests

node --test frontend/tests/test_app.mjs

Expected: # tests 25 # pass 25 # fail 0

Check 2: Python test suite

cd /home/bkrabach/dev/web-tmux
python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q

Expected: 118 passed, 0 failed

Check 3: Ruff linter

ruff check coordinator/ && ruff format --check coordinator/

Expected: All checks passed.

Check 4: File existence

ls -la frontend/app.js frontend/terminal.js frontend/tests/test_app.mjs

All three files must exist and be non-empty.

Check 5: Behavior smoke test

uvicorn coordinator.main:app --port 8000

Then open http://localhost:8000 and verify:

  • Session tiles render
  • connection status shown
  • Clicking a tile opens expanded view with working terminal
  • Ctrl+K opens command palette
  • Back button returns to grid

Scope Boundaries — Phase 2b Does NOT Include

Deferred to What
Phase 3 Caddy configuration (/terminal/ WebSocket proxy)
Phase 3 systemd service unit
Phase 3 Service worker / offline PWA support
Phase 3 Browser-tester E2E (Playwright)
Phase 3 Push notifications beyond Browser Notifications API
v2 Session creation / renaming UI
v2 Drag-to-reorder tiles

Note on Caddy: Until Caddy is configured (Phase 3), the WebSocket to /terminal/ will fail in production. In development, run ttyd manually on port 7682 and add a --proxy-headers Caddy route, OR test via the uvicorn dev server with a manually-started ttyd process attached to a tmux session by name.