- 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
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):
- Pure functions (Tasks 1-3)
- State variables (Task 4)
- DOM helpers /
apiwrapper (Task 4) initDeviceId,trackInteraction,DOMContentLoaded(Task 4)restoreState(Task 5)startPolling,pollSessions,setConnectionStatus(Task 6)renderGrid,buildTileHTML,escapeHtml,isMobile(Task 7)requestNotificationPermission,handleBellTransitions(Task 8)startHeartbeat,sendHeartbeat(Task 9)openSession,closeSession,showToast(Task 10)bindStaticEventListeners, palette functions (Task 11)openBottomSheet,closeBottomSheet,renderSheetList(Task 15)- 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'sessionPriorityalways returns'idle'sortByPriorityreturns 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:
- DevTools Console — no errors on load
- DevTools Application → Local Storage — key
tmux-web-device-idexists with a value liked-a3f9b2c1 - 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(addrestoreState)
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:
- Console —
restoreState is not a functionerror is gone - Network tab —
GET /api/staterequest fires on page load - If
active_sessionis null (no prior session): stays on grid view - If
active_sessionis set: should attemptopenSession(which still fails gracefully becauseopenSessiondoesn'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(addstartPolling,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:
- Network tab —
GET /api/sessionsfires on page load and every ~2 seconds #connection-statuselement in header shows●(green dot) when requests succeed- Console — no errors (ignore "renderGrid is not defined" — that is Task 7)
If you stop the coordinator and wait 3 polls:
- Status changes to
◌ slowafter first failure, then✕ offlineafter 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(addrenderGrid,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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
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:
- Session tiles rendered in the grid (one per tmux session)
- Each tile shows session name, last activity time, and terminal snapshot text
- Run
printf '\a'in a tmux session — within 2 seconds the tile gets amber border glow (.session-tile--bellclass) - 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(addrequestNotificationPermission,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
- Open
http://localhost:8000— browser should prompt for notification permission. Allow it. - Open another browser tab (so the dashboard tab is backgrounded:
document.hidden === true). - In a tmux session:
printf '\a' - Within 2 seconds: an OS notification appears: "Activity in: <session-name>"
- 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(addstartHeartbeat,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:
- Network tab —
POST /api/heartbeatfires on page load and every ~5 seconds - Request body includes:
device_id,label,viewing_session(null when on grid),view_mode("grid"),last_interaction_at(unix timestamp) - Response:
{"device_id": "d-...", "status": "ok"} GET /api/state→ checkdeviceskey — 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(addopenSession,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:
- Click a session tile — tile animates (zoom expand) to fill the viewport
- Expanded header shows session name
- Network —
POST /api/sessions/<name>/connectfires, returns{"active_session": ..., "ttyd_port": 7682} - Terminal area shows (empty —
terminal.jsisn't wired yet, added in Tasks 12-13) - Click the
←back button — returns to grid view - Grid tiles reappear
- Network —
DELETE /api/sessions/currentfires
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(addbindStaticEventListeners, 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:
- Press Ctrl+K or
`— command palette dialog opens - Type a session name fragment — list filters in real time
- Arrow Down/Up — highlighted item moves
- Press
2— second session opens directly - Press Escape — palette closes, stays in expanded view
- Press Ctrl+K again to reopen, press
G— returns to grid view - 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:
- DevTools Console — no
xterm.js not loadederror #terminal-containerhas acanvaselement inside it (xterm.js rendered)- Terminal cursor is visible and blinking
- The terminal is sized to fill the container (FitAddon working)
- 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(replaceconnectWebSocketstub 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:
- Terminal renders and shows the tmux session output
- Type commands — they execute in the real tmux session
- 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
- "Reconnecting…" overlay appears on
- 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(replaceinitVisualViewportstub 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
- Open
http://localhost:8000in Chrome DevTools → Toggle device toolbar (set to iPhone-size, < 600px wide) - Click a session tile to open expanded view
- Tap in the terminal area — software keyboard appears
- Verify terminal is still visible above the keyboard (container height adjusted)
- 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(addopenBottomSheet,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)
- Open
http://localhost:8000in DevTools mobile mode (< 600px wide) - Click a session tile — expanded view opens, floating pill appears at bottom with session name
- Tap the pill — bottom sheet slides up showing all sessions, sorted (bell first)
- Active session has
.sheet-item--activeclass (visually distinguished) - Tap a different session — sheet closes, new session opens
On desktop (> 600px wide):
- Pill is hidden (
.hiddenclass 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-headersCaddy route, OR test via the uvicorn dev server with a manually-started ttyd process attached to a tmux session by name.