feat: implement all P0 UX improvements - visual hierarchy, readable previews, search filter, default session
P0.1: Visual Hierarchy for Tiles
- Backend: Added last_activity_at to GET /api/sessions by querying tmux list-sessions -F '#{session_activity}'
Updated sessions.py with tuple return from enumerate_sessions and activity cache
Updated main.py poll loop to populate both session endpoints with activity data
- Frontend: sessionPriority() now returns 'active' (< 5 min) in addition to 'bell'/'idle'
Added .session-tile--active (accent left border) and .session-tile--stale (dimmed styling) CSS classes
Updated session-tile.js and sidebar-item.js to apply priority-based classes
- Fixed P4 hover fade bug: removed opacity:0 rule on .session-tile:hover .tile-meta
P0.2: Readable Tile Previews
- Wired --preview-font-size CSS variable (was set by JS but never consumed by CSS)
Changed default from 11px to 12px with line-height 1.2 (was 1.0)
- Reduced preview line count from 20 to 12 lines for better readability at larger size
P0.3: Search/Filter Bar
- New <search-filter> Lit component with live input, match count, and clear button
Replaces empty filter-bar div in index.html with functional search component
Integrated into renderGrid() for live filtering and bindStaticEventListeners() for re-render on input
P0.4: Default Session / Quick-Resume
- Updated restoreState() to fall back to default_session setting when no active_session in server state
Added existence check before opening to prevent errors on non-existent sessions
Files changed: sessions.py, main.py, app.js, style.css, session-tile.js, sidebar-item.js, utils.js, index.html
New files: search-filter.js (Lit component)
Test updates: test_api.py, test_frontend_css.py, test_integration.py, test_sessions.py
All 1306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
+40
-1
@@ -31,13 +31,17 @@ function formatTimestamp(ts) {
|
|||||||
/**
|
/**
|
||||||
* Return the priority label for a session object.
|
* Return the priority label for a session object.
|
||||||
* @param {object} session
|
* @param {object} session
|
||||||
* @returns {'bell'|'idle'}
|
* @returns {'bell'|'active'|'idle'}
|
||||||
*/
|
*/
|
||||||
function sessionPriority(session) {
|
function sessionPriority(session) {
|
||||||
const bell = session && session.bell;
|
const bell = session && session.bell;
|
||||||
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
||||||
return 'bell';
|
return 'bell';
|
||||||
}
|
}
|
||||||
|
const activity = session && session.last_activity_at;
|
||||||
|
if (activity && (Date.now() / 1000 - activity) < 300) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +327,17 @@ async function restoreState() {
|
|||||||
skipAnimation: true,
|
skipAnimation: true,
|
||||||
remoteId: state.active_remote_id || '',
|
remoteId: state.active_remote_id || '',
|
||||||
});
|
});
|
||||||
|
} else if (_serverSettings && _serverSettings.default_session) {
|
||||||
|
// Fallback to default_session setting if no active session
|
||||||
|
var defaultName = _serverSettings.default_session;
|
||||||
|
// Verify the session actually exists before opening
|
||||||
|
try {
|
||||||
|
var sessRes = await api('GET', '/api/sessions');
|
||||||
|
var sessList = await sessRes.json();
|
||||||
|
if (sessList.some(function(s) { return s.name === defaultName; })) {
|
||||||
|
await openSession(defaultName, { skipAnimation: true });
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[restoreState] could not restore previous session:', err);
|
console.warn('[restoreState] could not restore previous session:', err);
|
||||||
@@ -1528,7 +1543,23 @@ function renderGrid(sessions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search/filter bar integration
|
||||||
|
var filterBar = $('filter-bar');
|
||||||
|
var filterQuery = filterBar && filterBar.query ? filterBar.query : '';
|
||||||
|
|
||||||
var visible = getVisibleSessions(sessions);
|
var visible = getVisibleSessions(sessions);
|
||||||
|
var totalVisible = visible.length;
|
||||||
|
|
||||||
|
if (filterQuery) {
|
||||||
|
var q = filterQuery.toLowerCase();
|
||||||
|
visible = visible.filter(function(s) { return (s.name || '').toLowerCase().includes(q); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterBar && filterBar.tagName === 'SEARCH-FILTER') {
|
||||||
|
filterBar.total = totalVisible;
|
||||||
|
filterBar.count = visible.length;
|
||||||
|
}
|
||||||
|
|
||||||
var emptyState = $('empty-state');
|
var emptyState = $('empty-state');
|
||||||
|
|
||||||
if (visible.length === 0) {
|
if (visible.length === 0) {
|
||||||
@@ -3934,6 +3965,14 @@ function bindStaticEventListeners() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search/filter bar — re-render grid when filter text changes
|
||||||
|
var filterBar = $('filter-bar');
|
||||||
|
if (filterBar) {
|
||||||
|
filterBar.addEventListener('filter-change', function() {
|
||||||
|
renderGrid(_currentSessions || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Hover preview — component handles click internally; listen for preview-click
|
// Hover preview — component handles click internally; listen for preview-click
|
||||||
var hoverPreview = $('hover-preview');
|
var hoverPreview = $('hover-preview');
|
||||||
if (hoverPreview) {
|
if (hoverPreview) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
|
||||||
|
|
||||||
|
export class SearchFilter extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
query: { type: String, reflect: true },
|
||||||
|
placeholder: { type: String },
|
||||||
|
count: { type: Number },
|
||||||
|
total: { type: Number },
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host { display: block; }
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 var(--grid-padding, 16px);
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-tile, #10131C);
|
||||||
|
color: var(--text, #F0F6FF);
|
||||||
|
border: 1px solid var(--border, #2A3040);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 150ms;
|
||||||
|
}
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: var(--accent, #00D9F5);
|
||||||
|
}
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: var(--text-muted, #8E95A3);
|
||||||
|
}
|
||||||
|
.filter-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #8E95A3);
|
||||||
|
}
|
||||||
|
.filter-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #8E95A3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.filter-clear:hover { color: var(--text, #F0F6FF); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.query = '';
|
||||||
|
this.placeholder = 'Filter sessions...';
|
||||||
|
this.count = 0;
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onInput(e) {
|
||||||
|
this.query = e.target.value;
|
||||||
|
this.dispatchEvent(new CustomEvent('filter-change', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: { query: this.query },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClear() {
|
||||||
|
this.query = '';
|
||||||
|
this.dispatchEvent(new CustomEvent('filter-change', {
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
detail: { query: '' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeydown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this._onClear();
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const showCount = this.query && this.query.length > 0;
|
||||||
|
return html`
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input
|
||||||
|
class="filter-input"
|
||||||
|
type="text"
|
||||||
|
.value=${this.query}
|
||||||
|
placeholder=${this.placeholder}
|
||||||
|
@input=${this._onInput}
|
||||||
|
@keydown=${this._onKeydown}
|
||||||
|
aria-label="Filter sessions"
|
||||||
|
/>
|
||||||
|
${showCount ? html`
|
||||||
|
<span class="filter-count">${this.count} of ${this.total}</span>
|
||||||
|
<button class="filter-clear" @click=${this._onClear} aria-label="Clear filter">×</button>
|
||||||
|
` : html``}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('search-filter', SearchFilter);
|
||||||
@@ -64,15 +64,19 @@ export class SessionTile extends LitElement {
|
|||||||
get _classes() {
|
get _classes() {
|
||||||
let cls = 'session-tile';
|
let cls = 'session-tile';
|
||||||
const ind = this.activityIndicator;
|
const ind = this.activityIndicator;
|
||||||
|
const priority = this._priority;
|
||||||
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell';
|
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._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell';
|
||||||
if (this.mobile) cls += ` session-tile--tier-${this._priority}`;
|
if (priority === 'active') cls += ' session-tile--active';
|
||||||
|
const activity = this.session && this.session.last_activity_at;
|
||||||
|
if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' session-tile--stale';
|
||||||
|
if (this.mobile) cls += ` session-tile--tier-${priority}`;
|
||||||
return cls;
|
return cls;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _snapshotHtml() {
|
get _snapshotHtml() {
|
||||||
const snapshot = this.session.snapshot || '';
|
const snapshot = this.session.snapshot || '';
|
||||||
const lineCount = this.viewMode === 'fit' ? -80 : -20;
|
const lineCount = this.viewMode === 'fit' ? -80 : -12;
|
||||||
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
|
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
|
||||||
const allLines = snapshot.split('\n');
|
const allLines = snapshot.split('\n');
|
||||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
|
||||||
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
import { unsafeHTML } from '/vendor/lit/lit.min.js';
|
||||||
import { escapeHtml, ansiToHtml } from './utils.js';
|
import { escapeHtml, sessionPriority, ansiToHtml } from './utils.js';
|
||||||
import { icons } from './icons.js';
|
import { icons } from './icons.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,12 +47,20 @@ export class SidebarItem extends LitElement {
|
|||||||
return unseen && unseen > 0;
|
return unseen && unseen > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _priority() {
|
||||||
|
return sessionPriority(this.session);
|
||||||
|
}
|
||||||
|
|
||||||
get _classes() {
|
get _classes() {
|
||||||
let cls = 'sidebar-item';
|
let cls = 'sidebar-item';
|
||||||
if (this.active) cls += ' sidebar-item--active';
|
if (this.active) cls += ' sidebar-item--active';
|
||||||
const ind = this.activityIndicator;
|
const ind = this.activityIndicator;
|
||||||
|
const priority = this._priority;
|
||||||
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell';
|
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell';
|
||||||
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell';
|
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell';
|
||||||
|
if (priority === 'active') cls += ' sidebar-item--activity';
|
||||||
|
const activity = this.session && this.session.last_activity_at;
|
||||||
|
if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' sidebar-item--stale';
|
||||||
return cls;
|
return cls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ export class SidebarItem extends LitElement {
|
|||||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||||
allLines.pop();
|
allLines.pop();
|
||||||
}
|
}
|
||||||
return ansiToHtml(allLines.slice(-20).join('\n'));
|
return ansiToHtml(allLines.slice(-12).join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
_onClick(e) {
|
_onClick(e) {
|
||||||
|
|||||||
@@ -29,13 +29,17 @@ export function formatTimestamp(ts) {
|
|||||||
/**
|
/**
|
||||||
* Return the priority label for a session object.
|
* Return the priority label for a session object.
|
||||||
* @param {object} session
|
* @param {object} session
|
||||||
* @returns {'bell'|'idle'}
|
* @returns {'bell'|'active'|'idle'}
|
||||||
*/
|
*/
|
||||||
export function sessionPriority(session) {
|
export function sessionPriority(session) {
|
||||||
const bell = session && session.bell;
|
const bell = session && session.bell;
|
||||||
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
||||||
return 'bell';
|
return 'bell';
|
||||||
}
|
}
|
||||||
|
const activity = session && session.last_activity_at;
|
||||||
|
if (activity && (Date.now() / 1000 - activity) < 300) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
return 'idle';
|
return 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<connection-status id="connection-status" level="ok"></connection-status>
|
<connection-status id="connection-status" level="ok"></connection-status>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="filter-bar" class="filter-bar"></div>
|
<search-filter id="filter-bar"></search-filter>
|
||||||
<session-grid id="session-grid" class="session-grid" role="list"></session-grid>
|
<session-grid id="session-grid" class="session-grid" role="list"></session-grid>
|
||||||
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,6 +287,7 @@
|
|||||||
import '/components/session-grid.js';
|
import '/components/session-grid.js';
|
||||||
import '/components/store.js';
|
import '/components/store.js';
|
||||||
import '/components/view-dropdown.js';
|
import '/components/view-dropdown.js';
|
||||||
|
import '/components/search-filter.js';
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -51,6 +51,9 @@
|
|||||||
/* Typography */
|
/* Typography */
|
||||||
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||||
|
|
||||||
|
/* Preview */
|
||||||
|
--preview-font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline SVG icons — ensure consistent vertical alignment */
|
/* Inline SVG icons — ensure consistent vertical alignment */
|
||||||
@@ -214,6 +217,11 @@ body {
|
|||||||
border-left-color: var(--bell);
|
border-left-color: var(--bell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active tile (recent activity within last 5 minutes) */
|
||||||
|
.session-tile--active {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Tile header
|
Tile header
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -250,10 +258,7 @@ body {
|
|||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tile:hover .tile-meta,
|
/* P4 hover fade fix: show meta on hover instead of hiding */
|
||||||
.session-tile:focus-within .tile-meta {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bell notification badge */
|
/* Bell notification badge */
|
||||||
.tile-bell {
|
.tile-bell {
|
||||||
@@ -311,8 +316,8 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: var(--preview-font-size, 12px);
|
||||||
line-height: 1.0;
|
line-height: 1.2;
|
||||||
color: #c9d1d9; /* match xterm.js foreground */
|
color: #c9d1d9; /* match xterm.js foreground */
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -348,13 +353,21 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: var(--preview-font-size, 12px);
|
||||||
|
line-height: 1.2;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stale / idle tile dimming (no activity for >1 hour) */
|
||||||
|
.session-tile--stale .tile-name {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.session-tile--stale .tile-body {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
+11
-6
@@ -51,6 +51,7 @@ from muxplex.auth import (
|
|||||||
from muxplex.bells import apply_bell_clear_rule, process_bell_flags
|
from muxplex.bells import apply_bell_clear_rule, process_bell_flags
|
||||||
from muxplex.sessions import (
|
from muxplex.sessions import (
|
||||||
enumerate_sessions,
|
enumerate_sessions,
|
||||||
|
get_session_activity,
|
||||||
get_session_list,
|
get_session_list,
|
||||||
get_snapshots,
|
get_snapshots,
|
||||||
run_tmux,
|
run_tmux,
|
||||||
@@ -180,12 +181,12 @@ async def _run_poll_cycle() -> None:
|
|||||||
global _settings_sync_counter
|
global _settings_sync_counter
|
||||||
async with state_lock:
|
async with state_lock:
|
||||||
# 1. Enumerate live tmux sessions
|
# 1. Enumerate live tmux sessions
|
||||||
names = await enumerate_sessions()
|
names, activity = await enumerate_sessions()
|
||||||
name_set = set(names)
|
name_set = set(names)
|
||||||
|
|
||||||
# 2. Capture pane snapshots and update in-memory snapshot cache
|
# 2. Capture pane snapshots and update in-memory snapshot cache
|
||||||
new_snapshots = await snapshot_all(names)
|
new_snapshots = await snapshot_all(names)
|
||||||
update_session_cache(names, new_snapshots)
|
update_session_cache(names, new_snapshots, activity)
|
||||||
|
|
||||||
# 3. Load current persisted state
|
# 3. Load current persisted state
|
||||||
state = load_state()
|
state = load_state()
|
||||||
@@ -583,9 +584,10 @@ async def patch_state(patch: StatePatch) -> dict:
|
|||||||
|
|
||||||
@app.get("/api/sessions")
|
@app.get("/api/sessions")
|
||||||
async def get_sessions() -> list[dict]:
|
async def get_sessions() -> list[dict]:
|
||||||
"""Return list of sessions with name, snapshot, and bell data."""
|
"""Return list of sessions with name, snapshot, bell, and activity data."""
|
||||||
names = get_session_list()
|
names = get_session_list()
|
||||||
snapshots = get_snapshots()
|
snapshots = get_snapshots()
|
||||||
|
activities = get_session_activity()
|
||||||
state = await read_state()
|
state = await read_state()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -597,6 +599,7 @@ async def get_sessions() -> list[dict]:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"snapshot": snapshots.get(name, ""),
|
"snapshot": snapshots.get(name, ""),
|
||||||
"bell": bell,
|
"bell": bell,
|
||||||
|
"last_activity_at": activities.get(name),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
@@ -654,7 +657,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
|||||||
# Some commands (amplifier-workspace) create the session then
|
# Some commands (amplifier-workspace) create the session then
|
||||||
# try to attach (which fails without a TTY). If the session
|
# try to attach (which fails without a TTY). If the session
|
||||||
# exists despite the non-zero exit, treat it as success.
|
# exists despite the non-zero exit, treat it as success.
|
||||||
sessions = await enumerate_sessions()
|
sessions, _act = await enumerate_sessions()
|
||||||
if name in sessions:
|
if name in sessions:
|
||||||
_log.info(
|
_log.info(
|
||||||
"Session command exited %d but session '%s' exists -- "
|
"Session command exited %d but session '%s' exists -- "
|
||||||
@@ -699,9 +702,9 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
|||||||
# Eagerly refresh the session cache so the next GET /api/sessions
|
# Eagerly refresh the session cache so the next GET /api/sessions
|
||||||
# reflects the newly-created session without waiting for the poll loop.
|
# reflects the newly-created session without waiting for the poll loop.
|
||||||
try:
|
try:
|
||||||
fresh_names = await enumerate_sessions()
|
fresh_names, fresh_activity = await enumerate_sessions()
|
||||||
fresh_snapshots = await snapshot_all(fresh_names)
|
fresh_snapshots = await snapshot_all(fresh_names)
|
||||||
update_session_cache(fresh_names, fresh_snapshots)
|
update_session_cache(fresh_names, fresh_snapshots, fresh_activity)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # non-fatal; poll loop will catch up
|
pass # non-fatal; poll loop will catch up
|
||||||
|
|
||||||
@@ -1349,6 +1352,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
# Build local sessions with deviceId/deviceName/remoteId/sessionKey tags
|
# Build local sessions with deviceId/deviceName/remoteId/sessionKey tags
|
||||||
names = get_session_list()
|
names = get_session_list()
|
||||||
snapshots = get_snapshots()
|
snapshots = get_snapshots()
|
||||||
|
activities = get_session_activity()
|
||||||
state = await read_state()
|
state = await read_state()
|
||||||
local_sessions: list[dict] = []
|
local_sessions: list[dict] = []
|
||||||
for name in names:
|
for name in names:
|
||||||
@@ -1359,6 +1363,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"snapshot": snapshots.get(name, ""),
|
"snapshot": snapshots.get(name, ""),
|
||||||
"bell": bell,
|
"bell": bell,
|
||||||
|
"last_activity_at": activities.get(name),
|
||||||
"deviceId": local_device_id,
|
"deviceId": local_device_id,
|
||||||
"deviceName": local_device_name,
|
"deviceName": local_device_name,
|
||||||
"remoteId": None,
|
"remoteId": None,
|
||||||
|
|||||||
+51
-18
@@ -4,15 +4,17 @@ tmux session enumeration and snapshot helpers for the tmux-web muxplex.
|
|||||||
In-memory cache:
|
In-memory cache:
|
||||||
_session_list — most-recently-enumerated list of session names.
|
_session_list — most-recently-enumerated list of session names.
|
||||||
_snapshots — most-recently-captured pane text, keyed by session name.
|
_snapshots — most-recently-captured pane text, keyed by session name.
|
||||||
|
_session_activity — most-recently-captured session activity timestamps.
|
||||||
|
|
||||||
Public API:
|
Public API:
|
||||||
get_session_list() → list[str]
|
get_session_list() → list[str]
|
||||||
get_snapshots() → dict[str, str]
|
get_snapshots() → dict[str, str]
|
||||||
update_session_cache(names, snapshots) → None
|
get_session_activity() → dict[str, float]
|
||||||
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
|
update_session_cache(names, snapshots, activity) → None
|
||||||
enumerate_sessions() → list[str]
|
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
|
||||||
capture_pane(name, lines) → str
|
enumerate_sessions() → tuple[list[str], dict[str, float]]
|
||||||
snapshot_all(names) → dict[str, str]
|
capture_pane(name, lines) → str
|
||||||
|
snapshot_all(names) → dict[str, str]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -23,6 +25,7 @@ import asyncio
|
|||||||
|
|
||||||
_session_list: list[str] = []
|
_session_list: list[str] = []
|
||||||
_snapshots: dict[str, str] = {}
|
_snapshots: dict[str, str] = {}
|
||||||
|
_session_activity: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_session_list() -> list[str]:
|
def get_session_list() -> list[str]:
|
||||||
@@ -35,15 +38,28 @@ def get_snapshots() -> dict[str, str]:
|
|||||||
return dict(_snapshots)
|
return dict(_snapshots)
|
||||||
|
|
||||||
|
|
||||||
def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None:
|
def get_session_activity() -> dict[str, float]:
|
||||||
|
"""Return a copy of the cached session activity timestamp dict."""
|
||||||
|
return dict(_session_activity)
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_cache(
|
||||||
|
names: list[str],
|
||||||
|
snapshots: dict[str, str],
|
||||||
|
activity: dict[str, float] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Replace the in-memory caches with fresh data.
|
"""Replace the in-memory caches with fresh data.
|
||||||
|
|
||||||
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
|
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
|
||||||
Callers must pass the return value of snapshot_all() as *snapshots*.
|
Callers must pass the return value of snapshot_all() as *snapshots*.
|
||||||
|
Optionally accepts *activity* — a mapping of session name to Unix timestamp
|
||||||
|
of last activity (from ``#{session_activity}``).
|
||||||
"""
|
"""
|
||||||
global _session_list, _snapshots
|
global _session_list, _snapshots, _session_activity
|
||||||
_session_list = list(names)
|
_session_list = list(names)
|
||||||
_snapshots = snapshots
|
_snapshots = snapshots
|
||||||
|
if activity is not None:
|
||||||
|
_session_activity = activity
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -75,21 +91,38 @@ async def run_tmux(*args: str) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def enumerate_sessions() -> list[str]:
|
async def enumerate_sessions() -> tuple[list[str], dict[str, float]]:
|
||||||
"""Return the list of currently running tmux session names.
|
"""Return the list of currently running tmux session names and their activity timestamps.
|
||||||
|
|
||||||
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines,
|
Calls ``tmux list-sessions -F '#{session_name}\\t#{session_activity}'``,
|
||||||
and strips whitespace from each entry.
|
splits on newlines, and parses each line into a name and a Unix timestamp.
|
||||||
|
|
||||||
Returns [] if tmux is not running (RuntimeError from run_tmux).
|
Returns ``([], {})`` if tmux is not running (RuntimeError from run_tmux).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
output = await run_tmux("list-sessions", "-F", "#{session_name}")
|
output = await run_tmux(
|
||||||
|
"list-sessions", "-F", "#{session_name}\t#{session_activity}"
|
||||||
|
)
|
||||||
except (RuntimeError, FileNotFoundError):
|
except (RuntimeError, FileNotFoundError):
|
||||||
return []
|
return [], {}
|
||||||
|
|
||||||
names = [line.strip() for line in output.splitlines() if line.strip()]
|
names: list[str] = []
|
||||||
return names
|
activity: dict[str, float] = {}
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split("\t", 1)
|
||||||
|
name = parts[0].strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
names.append(name)
|
||||||
|
if len(parts) > 1:
|
||||||
|
try:
|
||||||
|
activity[name] = float(parts[1].strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return names, activity
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2857,7 +2857,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
|
|||||||
|
|
||||||
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
||||||
async def mock_enumerate():
|
async def mock_enumerate():
|
||||||
return ["build"]
|
return ["build"], {"build": 1700000000.0}
|
||||||
|
|
||||||
async def mock_snapshot_all(names):
|
async def mock_snapshot_all(names):
|
||||||
return {"build": "pane text"}
|
return {"build": "pane text"}
|
||||||
@@ -2868,7 +2868,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
|
|||||||
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
||||||
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"muxplex.main.update_session_cache", lambda names, snapshots: None
|
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
||||||
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
||||||
@@ -2969,7 +2969,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu
|
|||||||
|
|
||||||
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
||||||
async def mock_enumerate():
|
async def mock_enumerate():
|
||||||
return ["build"]
|
return ["build"], {"build": 1700000000.0}
|
||||||
|
|
||||||
async def mock_snapshot_all(names):
|
async def mock_snapshot_all(names):
|
||||||
return {"build": "pane text"}
|
return {"build": "pane text"}
|
||||||
@@ -2980,7 +2980,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu
|
|||||||
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
||||||
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"muxplex.main.update_session_cache", lambda names, snapshots: None
|
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
||||||
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
||||||
|
|||||||
@@ -675,11 +675,11 @@ def test_tile_body_has_black_background():
|
|||||||
|
|
||||||
|
|
||||||
def test_tile_body_pre_has_xterm_line_height():
|
def test_tile_body_pre_has_xterm_line_height():
|
||||||
""".tile-body pre must use line-height: 1.0 to match xterm.js terminal."""
|
""".tile-body pre must use line-height: 1.2 for readable tile previews."""
|
||||||
css = read_css()
|
css = read_css()
|
||||||
block = _extract_rule_block(css, ".tile-body pre {")
|
block = _extract_rule_block(css, ".tile-body pre {")
|
||||||
assert "line-height: 1.0" in block, (
|
assert "line-height: 1.2" in block, (
|
||||||
".tile-body pre must use line-height: 1.0 (xterm.js default)"
|
".tile-body pre must use line-height: 1.2 (readable preview)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async def test_enumerate_sessions_finds_test_session(tmux_server):
|
|||||||
"""enumerate_sessions discovers the 'test' session on the isolated tmux server."""
|
"""enumerate_sessions discovers the 'test' session on the isolated tmux server."""
|
||||||
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||||
with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux):
|
with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux):
|
||||||
sessions = await enumerate_sessions()
|
sessions, activity = await enumerate_sessions()
|
||||||
assert "test" in sessions
|
assert "test" in sessions
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,36 +83,39 @@ async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess):
|
|||||||
|
|
||||||
|
|
||||||
async def test_enumerate_sessions_parses_newline_output(mock_subprocess):
|
async def test_enumerate_sessions_parses_newline_output(mock_subprocess):
|
||||||
"""enumerate_sessions() splits newline-separated output into a list of names."""
|
"""enumerate_sessions() splits tab-separated output into names and activity."""
|
||||||
with mock_subprocess("alpha\nbeta\ngamma\n"):
|
with mock_subprocess("alpha\t1700000001\nbeta\t1700000002\ngamma\t1700000003\n"):
|
||||||
result = await enumerate_sessions()
|
names, activity = await enumerate_sessions()
|
||||||
|
|
||||||
assert result == ["alpha", "beta", "gamma"]
|
assert names == ["alpha", "beta", "gamma"]
|
||||||
|
assert activity == {"alpha": 1700000001.0, "beta": 1700000002.0, "gamma": 1700000003.0}
|
||||||
|
|
||||||
|
|
||||||
async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess):
|
async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess):
|
||||||
"""enumerate_sessions() returns [] when tmux output is empty."""
|
"""enumerate_sessions() returns ([], {}) when tmux output is empty."""
|
||||||
with mock_subprocess(""):
|
with mock_subprocess(""):
|
||||||
result = await enumerate_sessions()
|
names, activity = await enumerate_sessions()
|
||||||
|
|
||||||
assert result == []
|
assert names == []
|
||||||
|
assert activity == {}
|
||||||
|
|
||||||
|
|
||||||
async def test_enumerate_sessions_strips_whitespace(mock_subprocess):
|
async def test_enumerate_sessions_strips_whitespace(mock_subprocess):
|
||||||
"""enumerate_sessions() strips leading/trailing whitespace from each name."""
|
"""enumerate_sessions() strips leading/trailing whitespace from each name."""
|
||||||
with mock_subprocess(" session1 \n session2 \n"):
|
with mock_subprocess(" session1 \t1700000001\n session2 \t1700000002\n"):
|
||||||
result = await enumerate_sessions()
|
names, activity = await enumerate_sessions()
|
||||||
|
|
||||||
assert result == ["session1", "session2"]
|
assert names == ["session1", "session2"]
|
||||||
|
|
||||||
|
|
||||||
async def test_enumerate_sessions_handles_tmux_error(mock_subprocess):
|
async def test_enumerate_sessions_handles_tmux_error(mock_subprocess):
|
||||||
"""enumerate_sessions() returns [] when run_tmux raises RuntimeError
|
"""enumerate_sessions() returns ([], {}) when run_tmux raises RuntimeError
|
||||||
(e.g. tmux server not running)."""
|
(e.g. tmux server not running)."""
|
||||||
with mock_subprocess(stdout="", stderr="no server running", returncode=1):
|
with mock_subprocess(stdout="", stderr="no server running", returncode=1):
|
||||||
result = await enumerate_sessions()
|
names, activity = await enumerate_sessions()
|
||||||
|
|
||||||
assert result == []
|
assert names == []
|
||||||
|
assert activity == {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user