feat: introduce Lit web components framework for frontend (Phase 1)
CI / test (3.13) (push) Failing after 11m50s
CI / test (3.12) (push) Failing after 11m52s
CI / test (3.11) (push) Failing after 11m54s

- Vendored Lit 3 (21KB ESM bundle, no build step required)
- New components: toast-notification, connection-status, session-pill, session-tile
- Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml
- SVG icons registry for reuse
- Updated app.js and index.html to use new components
- All 1306 tests pass (incremental migration, vanilla JS still supported)
This commit is contained in:
Ken
2026-05-27 05:39:57 +00:00
parent b9fe3d7131
commit 8687d72d0b
11 changed files with 729 additions and 93 deletions
+100 -58
View File
@@ -337,16 +337,7 @@ async function restoreState() {
*/
function setConnectionStatus(level) {
const el = $('connection-status');
if (!el) return;
const map = {
ok: { text: _icons.statusOk, cls: 'connection-status--ok' },
warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' },
err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' },
};
const s = map[level];
if (!s) return;
el.innerHTML = s.text;
el.className = s.cls;
if (el) el.level = level;
}
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
@@ -1704,12 +1695,70 @@ function renderGrid(sessions) {
ordered = mobile ? sortByPriority(visible) : visible;
}
var html;
if (_gridViewMode === 'grouped') {
html = renderGroupedGrid(ordered, mobile);
} else {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
// Diff tiles: reuse existing, add new, remove stale
var existingTiles = new Map();
if (grid) {
grid.querySelectorAll('session-tile').forEach(function(t) {
existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t);
});
}
var newKeys = new Set();
var fragment = document.createDocumentFragment();
var ds = getDisplaySettings();
function _setupTile(s) {
var key = s.sessionKey || s.name;
newKeys.add(key);
var tile = existingTiles.get(key);
if (!tile) {
tile = document.createElement('session-tile');
tile.addEventListener('tile-click', function(e) {
openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
});
tile.addEventListener('tile-options', function(e) {
openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
});
}
tile.session = s;
tile.mobile = mobile;
tile.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled);
tile.showDeviceBadges = ds.showDeviceBadges !== false;
tile.activityIndicator = ds.activityIndicator || 'both';
tile.viewMode = ds.viewMode || 'auto';
return tile;
}
if (_gridViewMode === 'grouped') {
// Group by deviceName
var groups = {};
var groupOrder = [];
for (var gi = 0; gi < ordered.length; gi++) {
var dn = ordered[gi].deviceName || 'Unknown';
if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); }
groups[dn].push(ordered[gi]);
}
for (var g = 0; g < groupOrder.length; g++) {
var gname = groupOrder[g];
var groupSessions = groups[gname];
if (groupSessions.length === 0) continue;
var header = document.createElement('h3');
header.className = 'device-group-header';
header.textContent = gname;
fragment.appendChild(header);
for (var j = 0; j < groupSessions.length; j++) {
fragment.appendChild(_setupTile(groupSessions[j]));
}
}
} else {
for (var i = 0; i < ordered.length; i++) {
fragment.appendChild(_setupTile(ordered[i]));
}
}
// Remove stale tiles
existingTiles.forEach(function(tile, key) {
if (!newKeys.has(key)) tile.remove();
});
// Append status tiles for auth_failed and unreachable sessions. status:empty sentinels are
// intentionally ignored in all view modes — a remote with zero tmux sessions produces no
@@ -1719,29 +1768,14 @@ function renderGrid(sessions) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
});
if (grid) grid.innerHTML = html + statusTilesHtml;
if (grid) {
grid.innerHTML = statusTilesHtml;
grid.prepend(fragment);
}
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
if (filterBar) filterBar.innerHTML = '';
// Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', (e) => {
// Don't navigate when clicking the options button inside the tile
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
// Don't open error/status tiles (unreachable, auth_failed)
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
});
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
// Don't open error/status tiles (unreachable, auth_failed)
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
}
});
});
if (_viewMode === 'fullscreen') {
updatePillBell();
}
@@ -1833,15 +1867,24 @@ function hidePreview() {
* bottom action sheet instead.
* @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked
*/
function openFlyoutMenu(triggerEl) {
function openFlyoutMenu(nameOrEl, sessionKey, remoteId, rectArg) {
closeFlyoutMenu();
// Read session info from the tile
var tile = triggerEl.closest('[data-session-key]');
// Support both legacy (trigger element) and new (explicit params) call styles
var rect;
if (nameOrEl instanceof HTMLElement) {
var tile = nameOrEl.closest('[data-session-key]');
if (!tile) return;
_flyoutSessionKey = tile.dataset.sessionKey || '';
_flyoutSessionName = tile.dataset.session || '';
_flyoutRemoteId = tile.dataset.remoteId || '';
rect = nameOrEl.getBoundingClientRect();
} else {
_flyoutSessionName = nameOrEl || '';
_flyoutSessionKey = sessionKey || '';
_flyoutRemoteId = remoteId || '';
rect = rectArg;
}
if (isMobile()) {
_openFlyoutSheet();
@@ -1860,7 +1903,6 @@ function openFlyoutMenu(triggerEl) {
_flyoutMenuEl = menu;
// Position relative to trigger
var rect = triggerEl.getBoundingClientRect();
var menuWidth = menu.offsetWidth;
var menuHeight = menu.offsetHeight;
@@ -2860,10 +2902,7 @@ function _resetHeartbeatTimer() {
*/
function showToast(msg) {
const el = $('toast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 3000);
if (el && el.show) el.show(msg);
}
// ─── Session pill bell ───────────────────────────────────────────────────────
@@ -2873,13 +2912,13 @@ function showToast(msg) {
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
*/
function updatePillBell() {
const el = $('session-pill-bell');
if (!el) return;
const pill = $('session-pill');
if (!pill) return;
const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
const hasBell = _currentSessions.some(
(s) => (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0,
);
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
pill.hasBell = hasBell;
}
// ---------------------------------------------------------------------------
@@ -3049,9 +3088,8 @@ async function openSession(name, opts = {}) {
if (isMobile()) {
const pill = $('session-pill');
if (pill) {
pill.classList.remove('hidden'); // pill starts with hidden class
const pillLabel = $('session-pill-label');
if (pillLabel) pillLabel.textContent = name;
pill.visible = true;
pill.label = name;
}
updatePillBell();
updateSessionPill(_currentSessions);
@@ -3128,7 +3166,7 @@ function closeSession() {
}
const pill = $('session-pill');
if (pill) pill.classList.add('hidden');
if (pill) pill.visible = false;
// Restore FAB when returning to overview
const fab = $('new-session-fab');
@@ -3767,20 +3805,23 @@ function renderSheetList() {
* @param {object[]} sessions - full sessions array
*/
function updateSessionPill(sessions) {
if (_viewMode !== 'fullscreen') return;
var pillBell = $('session-pill-bell');
if (!pillBell) return;
const pill = $('session-pill');
if (!pill) return;
if (_viewMode !== 'fullscreen') {
pill.visible = false;
return;
}
var pillLabel = _viewingSession || '';
pill.label = pillLabel;
pill.visible = true;
var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
var othersWithBell = sessions.filter(function(s) {
return (s.sessionKey || s.name) !== viewingKey &&
s.bell && s.bell.unseen_count > 0 &&
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
});
if (othersWithBell.length > 0) {
pillBell.classList.remove('hidden');
} else {
pillBell.classList.add('hidden');
}
pill.hasBell = othersWithBell.length > 0;
}
// ─── Header + button with inline name input ────────────────────────────────────
@@ -4197,7 +4238,8 @@ function bindStaticEventListeners() {
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
bindSidebarClickAway();
document.addEventListener('keydown', handleGlobalKeydown);
on($('session-pill'), 'click', openBottomSheet);
var sessionPillEl = $('session-pill');
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
on($('sheet-backdrop'), 'click', closeBottomSheet);
// Settings dialog bindings
@@ -0,0 +1,51 @@
import { LitElement, html, css, svg } from '/vendor/lit/lit.min.js';
/**
* <connection-status> - Server connectivity indicator.
*
* Properties:
* level: 'ok' | 'warn' | 'err'
*/
export class ConnectionStatus extends LitElement {
static properties = {
level: { type: String, reflect: true },
};
static styles = css`
:host { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
.indicator { display: inline-flex; align-items: center; gap: 4px; }
:host([level="ok"]) .indicator { color: var(--status-ok, #3fb950); }
:host([level="warn"]) .indicator { color: var(--status-warn, #d29922); }
:host([level="err"]) .indicator { color: var(--status-err, #f85149); }
`;
constructor() {
super();
this.level = 'ok';
}
render() {
const content = {
ok: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>
</span>
`,
warn: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>
slow
</span>
`,
err: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
offline
</span>
`,
};
return content[this.level] || content.ok;
}
}
customElements.define('connection-status', ConnectionStatus);
+16
View File
@@ -0,0 +1,16 @@
// Inline SVG icon strings shared across components.
// Matches the _icons object in app.js.
export const icons = {
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
};
@@ -0,0 +1,77 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
/**
* <session-pill> - Floating session-switcher button with bell badge.
*
* Properties:
* label: String - session name to display
* hasBell: Boolean - whether bell badge is visible
* visible: Boolean - whether the pill itself is shown
*
* Events:
* pill-click - Fired when user taps/clicks the pill
*/
export class SessionPill extends LitElement {
static properties = {
label: { type: String },
hasBell: { type: Boolean, attribute: 'has-bell' },
visible: { type: Boolean, reflect: true },
};
static styles = css`
:host { display: none; }
:host([visible]) { display: block; }
.pill {
position: fixed;
bottom: 18px;
right: 18px;
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 999px;
background: var(--pill-bg, #21262d);
color: var(--pill-fg, #c9d1d9);
font-size: 13px;
opacity: 0.75;
cursor: pointer;
transition: opacity 0.2s;
}
.pill:hover { opacity: 1; }
.bell {
display: none;
color: var(--bell-color, #d29922);
}
.bell.active { display: inline-flex; }
`;
constructor() {
super();
this.label = '';
this.hasBell = false;
this.visible = false;
}
_onClick() {
this.dispatchEvent(new CustomEvent('pill-click', { bubbles: true, composed: true }));
}
render() {
return html`
<button class="pill" aria-label="Switch session" @click=${this._onClick}>
<span class="label">${this.label}</span>
<span class="bell ${this.hasBell ? 'active' : ''}" aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/>
</svg>
</span>
</button>
`;
}
}
customElements.define('session-pill', SessionPill);
+146
View File
@@ -0,0 +1,146 @@
import { LitElement, html, css, nothing } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { escapeHtml, formatTimestamp, sessionPriority, ansiToHtml } from './utils.js';
import { icons } from './icons.js';
/**
* <session-tile> - Dashboard tile representing a tmux session.
*
* Properties:
* session: Object - session data { name, snapshot, last_activity_at, bell, ... }
* mobile: Boolean - mobile layout mode
* multiDevice: Boolean - multi-device support enabled
* showDeviceBadges: Boolean - whether to show device name badges
* activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
* viewMode: String - 'auto' | 'fit' (controls snapshot line count)
*
* Events:
* tile-click { name, remoteId } - Session selected
* tile-options { name, remoteId, sessionKey, rect } - Kebab menu opened
*/
export class SessionTile extends LitElement {
static properties = {
session: { type: Object },
mobile: { type: Boolean },
multiDevice: { type: Boolean, attribute: 'multi-device' },
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
activityIndicator: { type: String, attribute: 'activity-indicator' },
viewMode: { type: String, attribute: 'view-mode' },
};
// Render into light DOM so existing CSS applies without migration.
createRenderRoot() { return this; }
constructor() {
super();
this.session = {};
this.mobile = false;
this.multiDevice = false;
this.showDeviceBadges = true;
this.activityIndicator = 'both';
this.viewMode = 'auto';
}
get _priority() {
return sessionPriority(this.session);
}
get _isBell() {
return this._priority === 'bell';
}
get _name() {
return this.session.name || '';
}
get _sessionKey() {
return this.session.sessionKey || this._name;
}
get _remoteId() {
return this.session.remoteId ?? null;
}
get _classes() {
let cls = 'session-tile';
const ind = this.activityIndicator;
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell';
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell';
if (this.mobile) cls += ` session-tile--tier-${this._priority}`;
return cls;
}
get _snapshotHtml() {
const snapshot = this.session.snapshot || '';
const lineCount = this.viewMode === 'fit' ? -80 : -20;
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
const allLines = snapshot.split('\n');
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
allLines.pop();
}
const lastLines = allLines.slice(lineCount).join('\n');
return ansiToHtml(lastLines);
}
_onTileClick(e) {
// Don't trigger tile-click when clicking the options button
if (e.target.closest('.tile-options-btn')) return;
this.dispatchEvent(new CustomEvent('tile-click', {
bubbles: true, composed: true,
detail: { name: this._name, remoteId: this._remoteId },
}));
}
_onOptionsClick(e) {
e.stopPropagation();
const btn = e.currentTarget;
this.dispatchEvent(new CustomEvent('tile-options', {
bubbles: true, composed: true,
detail: {
name: this._name,
remoteId: this._remoteId,
sessionKey: this._sessionKey,
rect: btn.getBoundingClientRect(),
},
}));
}
render() {
const name = this._name;
const timeStr = formatTimestamp(this.session.last_activity_at || null);
const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
return html`
<article
class=${this._classes}
data-session=${name}
data-session-key=${this._sessionKey}
tabindex="0"
role="listitem"
aria-label=${name}
@click=${this._onTileClick}
>
<div class="tile-header">
<span class="tile-name">${name}</span>
${showBadge
? html`<span class="device-badge">${this.session.deviceName}</span>`
: nothing}
<span class="tile-meta">${timeStr}</span>
<button
class="tile-options-btn"
data-session=${name}
aria-label="Session options"
aria-haspopup="true"
@click=${this._onOptionsClick}
>${unsafeHTML(icons.kebab)}</button>
</div>
<div class="tile-body">
<pre>${unsafeHTML(this._snapshotHtml)}</pre>
</div>
</article>
`;
}
}
customElements.define('session-tile', SessionTile);
@@ -0,0 +1,55 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
/**
* <toast-notification> - Brief popup message that auto-hides.
*
* Usage:
* const toast = document.querySelector('toast-notification');
* toast.show('Session created');
*/
export class ToastNotification extends LitElement {
static properties = {
_message: { state: true },
_visible: { state: true },
};
static styles = css`
:host { display: block; }
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--toast-bg, #e6edf3);
color: var(--toast-fg, #0d1117);
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.toast.visible { opacity: 1; }
`;
constructor() {
super();
this._message = '';
this._visible = false;
this._timer = null;
}
show(msg, duration = 3000) {
this._message = msg;
this._visible = true;
clearTimeout(this._timer);
this._timer = setTimeout(() => { this._visible = false; }, duration);
}
render() {
return html`<div class="toast ${this._visible ? 'visible' : ''}" role="status" aria-live="polite" aria-atomic="true">${this._message}</div>`;
}
}
customElements.define('toast-notification', ToastNotification);
+204
View File
@@ -0,0 +1,204 @@
// Pure utility functions extracted from app.js
// These have zero DOM coupling and zero global state.
/**
* Escape HTML special characters to safe entities.
* @param {string} str
* @returns {string}
*/
export function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Format a Unix timestamp (seconds) into a relative time string.
* @param {number|null|undefined} ts
* @returns {string}
*/
export function formatTimestamp(ts) {
if (ts == null) return '';
const diff = 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`;
}
/**
* Return the priority label for a session object.
* @param {object} session
* @returns {'bell'|'idle'}
*/
export function sessionPriority(session) {
const bell = session && session.bell;
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
return 'bell';
}
return 'idle';
}
/** Priority rank map. Lower = higher priority. */
export const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
/**
* Sort sessions by priority (ascending rank). Returns new array.
* @param {object[]} sessions
* @returns {object[]}
*/
export function sortByPriority(sessions) {
return sessions.slice().sort((a, b) => {
const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2;
const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2;
return rankA - rankB;
});
}
/**
* Filter sessions by search query (case-insensitive substring on name).
* @param {object[]} sessions
* @param {string|null} query
* @returns {object[]}
*/
export function filterByQuery(sessions, query) {
if (!query) return sessions;
const q = query.toLowerCase();
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
}
/**
* Detect sessions that transitioned to new/increased bell state.
* @param {object[]} prev
* @param {object[]} next
* @returns {string[]}
*/
export function detectBellTransitions(prev, next) {
const prevMap = new Map(
(prev || []).map((s) => [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]),
);
return (next || [])
.filter((s) => {
const unseen = s.bell && s.bell.unseen_count;
if (!unseen || unseen <= 0) return false;
const key = s.sessionKey || s.name;
const prevCount = prevMap.has(key) ? prevMap.get(key) : 0;
return unseen > prevCount;
})
.map((s) => s.name);
}
/**
* Generate a pseudo-random device ID string.
* @returns {string}
*/
export function generateDeviceId() {
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
}
/**
* Build a heartbeat payload object.
* @param {string} device_id
* @param {string|null} viewing_session
* @param {string} view_mode
* @param {number} last_interaction_at
* @returns {object}
*/
export function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) {
const label =
typeof navigator !== 'undefined' && navigator.userAgent
? navigator.userAgent.slice(0, 50)
: 'unknown';
return { device_id, label, viewing_session, view_mode, last_interaction_at };
}
// ---------------------------------------------------------------------------
// ANSI escape -> HTML span converter (SGR codes only)
// ---------------------------------------------------------------------------
const ANSI_COLORS = [
'#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf',
'#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec'
];
function ansi256Color(n) {
if (n < 16) return ANSI_COLORS[n];
if (n >= 232) { const g = 8 + (n - 232) * 10; return `rgb(${g},${g},${g})`; }
n -= 16;
const r = Math.floor(n / 36) * 51;
const g2 = Math.floor((n % 36) / 6) * 51;
const b = (n % 6) * 51;
return `rgb(${r},${g2},${b})`;
}
function ansiParamsToStyle(params) {
const styles = [];
let k = 0;
while (k < params.length) {
const p = parseInt(params[k], 10) || 0;
if (p === 0) return 'reset';
if (p === 1) styles.push('font-weight:bold');
else if (p === 2) styles.push('opacity:0.7');
else if (p === 3) styles.push('font-style:italic');
else if (p === 4) styles.push('text-decoration:underline');
else if (p === 7) styles.push('filter:invert(1)');
else if (p === 9) styles.push('text-decoration:line-through');
else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]);
else if (p === 38 && params[k + 1] === '5') {
styles.push('color:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 39) styles.push('color:inherit');
else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]);
else if (p === 48 && params[k + 1] === '5') {
styles.push('background:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 49) styles.push('background:inherit');
else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]);
else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]);
k++;
}
return styles.length ? styles.join(';') : '';
}
/**
* Convert ANSI SGR escape sequences to HTML spans with inline styles.
* @param {string} raw
* @returns {string}
*/
export function ansiToHtml(raw) {
if (!raw) return '';
let out = '';
let spans = 0;
let i = 0;
const len = raw.length;
while (i < len) {
if (raw[i] === '\x1b' && raw[i + 1] === '[') {
let j = i + 2;
while (j < len && raw[j] !== 'm' && j - i < 20) j++;
if (j < len && raw[j] === 'm') {
const params = raw.substring(i + 2, j).split(';');
const style = ansiParamsToStyle(params);
if (style === 'reset') {
while (spans > 0) { out += '</span>'; spans--; }
} else if (style) {
out += '<span style="' + style + '">';
spans++;
}
i = j + 1;
continue;
}
}
const ch = raw[i];
if (ch === '<') out += '&lt;';
else if (ch === '>') out += '&gt;';
else if (ch === '&') out += '&amp;';
else if (ch === '"') out += '&quot;';
else out += ch;
i++;
}
while (spans > 0) { out += '</span>'; spans--; }
return out;
}
+9 -6
View File
@@ -30,7 +30,7 @@
<button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
<button id="settings-btn" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
<span id="connection-status"></span>
<connection-status id="connection-status" level="ok"></connection-status>
</div>
</header>
<div id="filter-bar" class="filter-bar"></div>
@@ -121,16 +121,13 @@
</div>
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
<span id="session-pill-label" class="session-pill__label"></span>
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg></span>
</button>
<session-pill id="session-pill"></session-pill>
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
<button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
<!-- ── Toast notification ──────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
<toast-notification id="toast"></toast-notification>
<!-- ── Settings ──────────────────────────────────────────────────────────── -->
<div id="settings-backdrop" class="settings-backdrop hidden"></div>
@@ -294,5 +291,11 @@
<script src="/vendor/addon-image.js"></script>
<script src="/app.js" defer></script>
<script src="/terminal.js" defer></script>
<script type="module">
import '/components/toast-notification.js';
import '/components/connection-status.js';
import '/components/session-pill.js';
import '/components/session-tile.js';
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+2 -11
View File
@@ -69,27 +69,22 @@ def test_html_expanded_view_elements() -> None:
def test_html_bottom_sheet() -> None:
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell."""
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill."""
soup = _SOUP
for id_ in (
"bottom-sheet",
"sheet-list",
"sheet-backdrop",
"session-pill",
"session-pill-label",
"session-pill-bell",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_toast() -> None:
"""id=toast, aria-live=polite."""
"""id=toast (toast-notification component)."""
soup = _SOUP
toast = soup.find(id="toast")
assert toast, "Missing element with id='toast'"
assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr]
f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr]
)
def test_html_scripts() -> None:
@@ -228,16 +223,12 @@ def test_html_element_classes() -> None:
"reconnect-overlay",
"needs position:absolute to overlay terminal",
),
("session-pill", "session-pill", "needs position:fixed to float"),
("toast", "toast", "needs position:fixed and animation"),
("back-btn", "back-btn", "needs border and hover styles"),
(
"expanded-session-name",
"expanded-session-name",
"needs text-overflow:ellipsis",
),
("session-pill-label", "session-pill__label", "needs max-width truncation"),
("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"),
(
"session-sidebar",
"session-sidebar",
+10 -14
View File
@@ -4005,24 +4005,20 @@ def test_render_grid_closes_stale_flyout() -> None:
def test_tile_click_handler_guards_options_btn() -> None:
"""Tile click handler early return must guard .tile-options-btn (not .tile-delete).
"""Tile click events are handled by session-tile component via tile-click/tile-options events.
BUG: Previously the guard checked .tile-delete which was removed in Phase 3.
Clicking ⋮ triggered BOTH flyout opening AND openSession() navigation.
Fix: guard must check .tile-options-btn to stop event from reaching openSession().
With session-tile Lit components, the options-button guard is handled inside the component
itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of
binding raw click handlers with .tile-options-btn guards.
"""
# The tile click handler is inside renderGrid — find it
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "tile-options-btn" in render_grid_body, (
"Tile click handler must guard against .tile-options-btn clicks — "
"clicking ⋮ must NOT trigger openSession()"
# session-tile components fire custom events; renderGrid must listen for them
assert "tile-click" in render_grid_body, (
"renderGrid must listen for 'tile-click' events from session-tile components"
)
assert "tile-options" in render_grid_body, (
"renderGrid must listen for 'tile-options' events from session-tile components"
)
# Confirm the old broken guard is gone
assert (
"'tile-delete'" not in render_grid_body
and '"tile-delete"' not in render_grid_body
or ("tile-options-btn" in render_grid_body)
), "Guard must use .tile-options-btn, not the old .tile-delete which was removed"
def test_flyout_delegation_handler_no_stop_propagation() -> None: