feat: introduce Lit web components framework for frontend (Phase 1)
- 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:
+100
-58
@@ -337,16 +337,7 @@ async function restoreState() {
|
|||||||
*/
|
*/
|
||||||
function setConnectionStatus(level) {
|
function setConnectionStatus(level) {
|
||||||
const el = $('connection-status');
|
const el = $('connection-status');
|
||||||
if (!el) return;
|
if (el) el.level = level;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1704,12 +1695,70 @@ function renderGrid(sessions) {
|
|||||||
ordered = mobile ? sortByPriority(visible) : visible;
|
ordered = mobile ? sortByPriority(visible) : visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html;
|
// Diff tiles: reuse existing, add new, remove stale
|
||||||
if (_gridViewMode === 'grouped') {
|
var existingTiles = new Map();
|
||||||
html = renderGroupedGrid(ordered, mobile);
|
if (grid) {
|
||||||
} else {
|
grid.querySelectorAll('session-tile').forEach(function(t) {
|
||||||
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
|
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
|
// 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
|
// 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');
|
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
||||||
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
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)
|
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
|
||||||
if (filterBar) filterBar.innerHTML = '';
|
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') {
|
if (_viewMode === 'fullscreen') {
|
||||||
updatePillBell();
|
updatePillBell();
|
||||||
}
|
}
|
||||||
@@ -1833,15 +1867,24 @@ function hidePreview() {
|
|||||||
* bottom action sheet instead.
|
* bottom action sheet instead.
|
||||||
* @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked
|
* @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked
|
||||||
*/
|
*/
|
||||||
function openFlyoutMenu(triggerEl) {
|
function openFlyoutMenu(nameOrEl, sessionKey, remoteId, rectArg) {
|
||||||
closeFlyoutMenu();
|
closeFlyoutMenu();
|
||||||
|
|
||||||
// Read session info from the tile
|
// Support both legacy (trigger element) and new (explicit params) call styles
|
||||||
var tile = triggerEl.closest('[data-session-key]');
|
var rect;
|
||||||
|
if (nameOrEl instanceof HTMLElement) {
|
||||||
|
var tile = nameOrEl.closest('[data-session-key]');
|
||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
_flyoutSessionKey = tile.dataset.sessionKey || '';
|
_flyoutSessionKey = tile.dataset.sessionKey || '';
|
||||||
_flyoutSessionName = tile.dataset.session || '';
|
_flyoutSessionName = tile.dataset.session || '';
|
||||||
_flyoutRemoteId = tile.dataset.remoteId || '';
|
_flyoutRemoteId = tile.dataset.remoteId || '';
|
||||||
|
rect = nameOrEl.getBoundingClientRect();
|
||||||
|
} else {
|
||||||
|
_flyoutSessionName = nameOrEl || '';
|
||||||
|
_flyoutSessionKey = sessionKey || '';
|
||||||
|
_flyoutRemoteId = remoteId || '';
|
||||||
|
rect = rectArg;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
_openFlyoutSheet();
|
_openFlyoutSheet();
|
||||||
@@ -1860,7 +1903,6 @@ function openFlyoutMenu(triggerEl) {
|
|||||||
_flyoutMenuEl = menu;
|
_flyoutMenuEl = menu;
|
||||||
|
|
||||||
// Position relative to trigger
|
// Position relative to trigger
|
||||||
var rect = triggerEl.getBoundingClientRect();
|
|
||||||
var menuWidth = menu.offsetWidth;
|
var menuWidth = menu.offsetWidth;
|
||||||
var menuHeight = menu.offsetHeight;
|
var menuHeight = menu.offsetHeight;
|
||||||
|
|
||||||
@@ -2860,10 +2902,7 @@ function _resetHeartbeatTimer() {
|
|||||||
*/
|
*/
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
const el = $('toast');
|
const el = $('toast');
|
||||||
if (!el) return;
|
if (el && el.show) el.show(msg);
|
||||||
el.textContent = msg;
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session pill bell ───────────────────────────────────────────────────────
|
// ─── Session pill bell ───────────────────────────────────────────────────────
|
||||||
@@ -2873,13 +2912,13 @@ function showToast(msg) {
|
|||||||
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
|
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
|
||||||
*/
|
*/
|
||||||
function updatePillBell() {
|
function updatePillBell() {
|
||||||
const el = $('session-pill-bell');
|
const pill = $('session-pill');
|
||||||
if (!el) return;
|
if (!pill) return;
|
||||||
const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
|
const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
|
||||||
const hasBell = _currentSessions.some(
|
const hasBell = _currentSessions.some(
|
||||||
(s) => (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0,
|
(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()) {
|
if (isMobile()) {
|
||||||
const pill = $('session-pill');
|
const pill = $('session-pill');
|
||||||
if (pill) {
|
if (pill) {
|
||||||
pill.classList.remove('hidden'); // pill starts with hidden class
|
pill.visible = true;
|
||||||
const pillLabel = $('session-pill-label');
|
pill.label = name;
|
||||||
if (pillLabel) pillLabel.textContent = name;
|
|
||||||
}
|
}
|
||||||
updatePillBell();
|
updatePillBell();
|
||||||
updateSessionPill(_currentSessions);
|
updateSessionPill(_currentSessions);
|
||||||
@@ -3128,7 +3166,7 @@ function closeSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pill = $('session-pill');
|
const pill = $('session-pill');
|
||||||
if (pill) pill.classList.add('hidden');
|
if (pill) pill.visible = false;
|
||||||
|
|
||||||
// Restore FAB when returning to overview
|
// Restore FAB when returning to overview
|
||||||
const fab = $('new-session-fab');
|
const fab = $('new-session-fab');
|
||||||
@@ -3767,20 +3805,23 @@ function renderSheetList() {
|
|||||||
* @param {object[]} sessions - full sessions array
|
* @param {object[]} sessions - full sessions array
|
||||||
*/
|
*/
|
||||||
function updateSessionPill(sessions) {
|
function updateSessionPill(sessions) {
|
||||||
if (_viewMode !== 'fullscreen') return;
|
const pill = $('session-pill');
|
||||||
var pillBell = $('session-pill-bell');
|
if (!pill) return;
|
||||||
if (!pillBell) return;
|
if (_viewMode !== 'fullscreen') {
|
||||||
|
pill.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pillLabel = _viewingSession || '';
|
||||||
|
pill.label = pillLabel;
|
||||||
|
pill.visible = true;
|
||||||
|
|
||||||
var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
|
var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
|
||||||
var othersWithBell = sessions.filter(function(s) {
|
var othersWithBell = sessions.filter(function(s) {
|
||||||
return (s.sessionKey || s.name) !== viewingKey &&
|
return (s.sessionKey || s.name) !== viewingKey &&
|
||||||
s.bell && s.bell.unseen_count > 0 &&
|
s.bell && s.bell.unseen_count > 0 &&
|
||||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||||
});
|
});
|
||||||
if (othersWithBell.length > 0) {
|
pill.hasBell = othersWithBell.length > 0;
|
||||||
pillBell.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
pillBell.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header + button with inline name input ────────────────────────────────────
|
// ─── Header + button with inline name input ────────────────────────────────────
|
||||||
@@ -4197,7 +4238,8 @@ function bindStaticEventListeners() {
|
|||||||
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
|
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
|
||||||
bindSidebarClickAway();
|
bindSidebarClickAway();
|
||||||
document.addEventListener('keydown', handleGlobalKeydown);
|
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);
|
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||||
|
|
||||||
// Settings dialog bindings
|
// 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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 += '<';
|
||||||
|
else if (ch === '>') out += '>';
|
||||||
|
else if (ch === '&') out += '&';
|
||||||
|
else if (ch === '"') out += '"';
|
||||||
|
else out += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (spans > 0) { out += '</span>'; spans--; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -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="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="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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="filter-bar" class="filter-bar"></div>
|
<div id="filter-bar" class="filter-bar"></div>
|
||||||
@@ -121,16 +121,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
|
||||||
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
<session-pill id="session-pill"></session-pill>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
|
<!-- ── 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>
|
<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 ──────────────────────────────────────────────── -->
|
<!-- ── Toast notification ──────────────────────────────────────────────── -->
|
||||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
<toast-notification id="toast"></toast-notification>
|
||||||
|
|
||||||
<!-- ── Settings ──────────────────────────────────────────────────────────── -->
|
<!-- ── Settings ──────────────────────────────────────────────────────────── -->
|
||||||
<div id="settings-backdrop" class="settings-backdrop hidden"></div>
|
<div id="settings-backdrop" class="settings-backdrop hidden"></div>
|
||||||
@@ -294,5 +291,11 @@
|
|||||||
<script src="/vendor/addon-image.js"></script>
|
<script src="/vendor/addon-image.js"></script>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
<script src="/terminal.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+55
File diff suppressed because one or more lines are too long
@@ -69,27 +69,22 @@ def test_html_expanded_view_elements() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_html_bottom_sheet() -> 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
|
soup = _SOUP
|
||||||
for id_ in (
|
for id_ in (
|
||||||
"bottom-sheet",
|
"bottom-sheet",
|
||||||
"sheet-list",
|
"sheet-list",
|
||||||
"sheet-backdrop",
|
"sheet-backdrop",
|
||||||
"session-pill",
|
"session-pill",
|
||||||
"session-pill-label",
|
|
||||||
"session-pill-bell",
|
|
||||||
):
|
):
|
||||||
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
def test_html_toast() -> None:
|
def test_html_toast() -> None:
|
||||||
"""id=toast, aria-live=polite."""
|
"""id=toast (toast-notification component)."""
|
||||||
soup = _SOUP
|
soup = _SOUP
|
||||||
toast = soup.find(id="toast")
|
toast = soup.find(id="toast")
|
||||||
assert toast, "Missing element with 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:
|
def test_html_scripts() -> None:
|
||||||
@@ -228,16 +223,12 @@ def test_html_element_classes() -> None:
|
|||||||
"reconnect-overlay",
|
"reconnect-overlay",
|
||||||
"needs position:absolute to overlay terminal",
|
"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"),
|
("back-btn", "back-btn", "needs border and hover styles"),
|
||||||
(
|
(
|
||||||
"expanded-session-name",
|
"expanded-session-name",
|
||||||
"expanded-session-name",
|
"expanded-session-name",
|
||||||
"needs text-overflow:ellipsis",
|
"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",
|
||||||
"session-sidebar",
|
"session-sidebar",
|
||||||
|
|||||||
@@ -4005,24 +4005,20 @@ def test_render_grid_closes_stale_flyout() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_tile_click_handler_guards_options_btn() -> 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.
|
With session-tile Lit components, the options-button guard is handled inside the component
|
||||||
Clicking ⋮ triggered BOTH flyout opening AND openSession() navigation.
|
itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of
|
||||||
Fix: guard must check .tile-options-btn to stop event from reaching openSession().
|
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]
|
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
|
||||||
assert "tile-options-btn" in render_grid_body, (
|
# session-tile components fire custom events; renderGrid must listen for them
|
||||||
"Tile click handler must guard against .tile-options-btn clicks — "
|
assert "tile-click" in render_grid_body, (
|
||||||
"clicking ⋮ must NOT trigger openSession()"
|
"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:
|
def test_flyout_delegation_handler_no_stop_propagation() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user