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.
|
||||
* @param {object} session
|
||||
* @returns {'bell'|'idle'}
|
||||
* @returns {'bell'|'active'|'idle'}
|
||||
*/
|
||||
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';
|
||||
}
|
||||
const activity = session && session.last_activity_at;
|
||||
if (activity && (Date.now() / 1000 - activity) < 300) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
@@ -323,6 +327,17 @@ async function restoreState() {
|
||||
skipAnimation: true,
|
||||
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) {
|
||||
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 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');
|
||||
|
||||
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
|
||||
var hoverPreview = $('hover-preview');
|
||||
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() {
|
||||
let cls = 'session-tile';
|
||||
const ind = this.activityIndicator;
|
||||
const priority = this._priority;
|
||||
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}`;
|
||||
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;
|
||||
}
|
||||
|
||||
get _snapshotHtml() {
|
||||
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)
|
||||
const allLines = snapshot.split('\n');
|
||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html, nothing } 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';
|
||||
|
||||
/**
|
||||
@@ -47,12 +47,20 @@ export class SidebarItem extends LitElement {
|
||||
return unseen && unseen > 0;
|
||||
}
|
||||
|
||||
get _priority() {
|
||||
return sessionPriority(this.session);
|
||||
}
|
||||
|
||||
get _classes() {
|
||||
let cls = 'sidebar-item';
|
||||
if (this.active) cls += ' sidebar-item--active';
|
||||
const ind = this.activityIndicator;
|
||||
const priority = this._priority;
|
||||
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--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;
|
||||
}
|
||||
|
||||
@@ -62,7 +70,7 @@ export class SidebarItem extends LitElement {
|
||||
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
|
||||
allLines.pop();
|
||||
}
|
||||
return ansiToHtml(allLines.slice(-20).join('\n'));
|
||||
return ansiToHtml(allLines.slice(-12).join('\n'));
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
|
||||
@@ -29,13 +29,17 @@ export function formatTimestamp(ts) {
|
||||
/**
|
||||
* Return the priority label for a session object.
|
||||
* @param {object} session
|
||||
* @returns {'bell'|'idle'}
|
||||
* @returns {'bell'|'active'|'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';
|
||||
}
|
||||
const activity = session && session.last_activity_at;
|
||||
if (activity && (Date.now() / 1000 - activity) < 300) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<connection-status id="connection-status" level="ok"></connection-status>
|
||||
</div>
|
||||
</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>
|
||||
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
||||
</div>
|
||||
@@ -287,6 +287,7 @@
|
||||
import '/components/session-grid.js';
|
||||
import '/components/store.js';
|
||||
import '/components/view-dropdown.js';
|
||||
import '/components/search-filter.js';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
/* Typography */
|
||||
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||
|
||||
/* Preview */
|
||||
--preview-font-size: 12px;
|
||||
}
|
||||
|
||||
/* Inline SVG icons — ensure consistent vertical alignment */
|
||||
@@ -214,6 +217,11 @@ body {
|
||||
border-left-color: var(--bell);
|
||||
}
|
||||
|
||||
/* Active tile (recent activity within last 5 minutes) */
|
||||
.session-tile--active {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tile header
|
||||
============================================================ */
|
||||
@@ -250,10 +258,7 @@ body {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.session-tile:hover .tile-meta,
|
||||
.session-tile:focus-within .tile-meta {
|
||||
opacity: 0;
|
||||
}
|
||||
/* P4 hover fade fix: show meta on hover instead of hiding */
|
||||
|
||||
/* Bell notification badge */
|
||||
.tile-bell {
|
||||
@@ -311,8 +316,8 @@ body {
|
||||
right: 0;
|
||||
padding: 6px 8px;
|
||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.0;
|
||||
font-size: var(--preview-font-size, 12px);
|
||||
line-height: 1.2;
|
||||
color: #c9d1d9; /* match xterm.js foreground */
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
@@ -348,13 +353,21 @@ body {
|
||||
inset: 0;
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-size: var(--preview-font-size, 12px);
|
||||
line-height: 1.2;
|
||||
color: var(--text-dim);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
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.sessions import (
|
||||
enumerate_sessions,
|
||||
get_session_activity,
|
||||
get_session_list,
|
||||
get_snapshots,
|
||||
run_tmux,
|
||||
@@ -180,12 +181,12 @@ async def _run_poll_cycle() -> None:
|
||||
global _settings_sync_counter
|
||||
async with state_lock:
|
||||
# 1. Enumerate live tmux sessions
|
||||
names = await enumerate_sessions()
|
||||
names, activity = await enumerate_sessions()
|
||||
name_set = set(names)
|
||||
|
||||
# 2. Capture pane snapshots and update in-memory snapshot cache
|
||||
new_snapshots = await snapshot_all(names)
|
||||
update_session_cache(names, new_snapshots)
|
||||
update_session_cache(names, new_snapshots, activity)
|
||||
|
||||
# 3. Load current persisted state
|
||||
state = load_state()
|
||||
@@ -583,9 +584,10 @@ async def patch_state(patch: StatePatch) -> dict:
|
||||
|
||||
@app.get("/api/sessions")
|
||||
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()
|
||||
snapshots = get_snapshots()
|
||||
activities = get_session_activity()
|
||||
state = await read_state()
|
||||
|
||||
result = []
|
||||
@@ -597,6 +599,7 @@ async def get_sessions() -> list[dict]:
|
||||
"name": name,
|
||||
"snapshot": snapshots.get(name, ""),
|
||||
"bell": bell,
|
||||
"last_activity_at": activities.get(name),
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -654,7 +657,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
||||
# Some commands (amplifier-workspace) create the session then
|
||||
# try to attach (which fails without a TTY). If the session
|
||||
# exists despite the non-zero exit, treat it as success.
|
||||
sessions = await enumerate_sessions()
|
||||
sessions, _act = await enumerate_sessions()
|
||||
if name in sessions:
|
||||
_log.info(
|
||||
"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
|
||||
# reflects the newly-created session without waiting for the poll loop.
|
||||
try:
|
||||
fresh_names = await enumerate_sessions()
|
||||
fresh_names, fresh_activity = await enumerate_sessions()
|
||||
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:
|
||||
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
|
||||
names = get_session_list()
|
||||
snapshots = get_snapshots()
|
||||
activities = get_session_activity()
|
||||
state = await read_state()
|
||||
local_sessions: list[dict] = []
|
||||
for name in names:
|
||||
@@ -1359,6 +1363,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
||||
"name": name,
|
||||
"snapshot": snapshots.get(name, ""),
|
||||
"bell": bell,
|
||||
"last_activity_at": activities.get(name),
|
||||
"deviceId": local_device_id,
|
||||
"deviceName": local_device_name,
|
||||
"remoteId": None,
|
||||
|
||||
+51
-18
@@ -4,15 +4,17 @@ tmux session enumeration and snapshot helpers for the tmux-web muxplex.
|
||||
In-memory cache:
|
||||
_session_list — most-recently-enumerated list of session names.
|
||||
_snapshots — most-recently-captured pane text, keyed by session name.
|
||||
_session_activity — most-recently-captured session activity timestamps.
|
||||
|
||||
Public API:
|
||||
get_session_list() → list[str]
|
||||
get_snapshots() → dict[str, str]
|
||||
update_session_cache(names, snapshots) → None
|
||||
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
|
||||
enumerate_sessions() → list[str]
|
||||
capture_pane(name, lines) → str
|
||||
snapshot_all(names) → dict[str, str]
|
||||
get_session_list() → list[str]
|
||||
get_snapshots() → dict[str, str]
|
||||
get_session_activity() → dict[str, float]
|
||||
update_session_cache(names, snapshots, activity) → None
|
||||
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
|
||||
enumerate_sessions() → tuple[list[str], dict[str, float]]
|
||||
capture_pane(name, lines) → str
|
||||
snapshot_all(names) → dict[str, str]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -23,6 +25,7 @@ import asyncio
|
||||
|
||||
_session_list: list[str] = []
|
||||
_snapshots: dict[str, str] = {}
|
||||
_session_activity: dict[str, float] = {}
|
||||
|
||||
|
||||
def get_session_list() -> list[str]:
|
||||
@@ -35,15 +38,28 @@ def get_snapshots() -> dict[str, str]:
|
||||
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.
|
||||
|
||||
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
|
||||
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)
|
||||
_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]:
|
||||
"""Return the list of currently running tmux session names.
|
||||
async def enumerate_sessions() -> tuple[list[str], dict[str, float]]:
|
||||
"""Return the list of currently running tmux session names and their activity timestamps.
|
||||
|
||||
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines,
|
||||
and strips whitespace from each entry.
|
||||
Calls ``tmux list-sessions -F '#{session_name}\\t#{session_activity}'``,
|
||||
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:
|
||||
output = await run_tmux("list-sessions", "-F", "#{session_name}")
|
||||
output = await run_tmux(
|
||||
"list-sessions", "-F", "#{session_name}\t#{session_activity}"
|
||||
)
|
||||
except (RuntimeError, FileNotFoundError):
|
||||
return []
|
||||
return [], {}
|
||||
|
||||
names = [line.strip() for line in output.splitlines() if line.strip()]
|
||||
return names
|
||||
names: list[str] = []
|
||||
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
|
||||
async def mock_enumerate():
|
||||
return ["build"]
|
||||
return ["build"], {"build": 1700000000.0}
|
||||
|
||||
async def mock_snapshot_all(names):
|
||||
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.snapshot_all", mock_snapshot_all)
|
||||
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.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
|
||||
async def mock_enumerate():
|
||||
return ["build"]
|
||||
return ["build"], {"build": 1700000000.0}
|
||||
|
||||
async def mock_snapshot_all(names):
|
||||
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.snapshot_all", mock_snapshot_all)
|
||||
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.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():
|
||||
""".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()
|
||||
block = _extract_rule_block(css, ".tile-body pre {")
|
||||
assert "line-height: 1.0" in block, (
|
||||
".tile-body pre must use line-height: 1.0 (xterm.js default)"
|
||||
assert "line-height: 1.2" in block, (
|
||||
".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."""
|
||||
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||
with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux):
|
||||
sessions = await enumerate_sessions()
|
||||
sessions, activity = await enumerate_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):
|
||||
"""enumerate_sessions() splits newline-separated output into a list of names."""
|
||||
with mock_subprocess("alpha\nbeta\ngamma\n"):
|
||||
result = await enumerate_sessions()
|
||||
"""enumerate_sessions() splits tab-separated output into names and activity."""
|
||||
with mock_subprocess("alpha\t1700000001\nbeta\t1700000002\ngamma\t1700000003\n"):
|
||||
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):
|
||||
"""enumerate_sessions() returns [] when tmux output is empty."""
|
||||
"""enumerate_sessions() returns ([], {}) when tmux output is empty."""
|
||||
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):
|
||||
"""enumerate_sessions() strips leading/trailing whitespace from each name."""
|
||||
with mock_subprocess(" session1 \n session2 \n"):
|
||||
result = await enumerate_sessions()
|
||||
with mock_subprocess(" session1 \t1700000001\n session2 \t1700000002\n"):
|
||||
names, activity = await enumerate_sessions()
|
||||
|
||||
assert result == ["session1", "session2"]
|
||||
assert names == ["session1", "session2"]
|
||||
|
||||
|
||||
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)."""
|
||||
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