diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 424d07d..44fa0ed 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -406,6 +406,7 @@ function buildTileHTML(session, index, mobile) { `${bellHtml}${escapeHtml(timeStr)}` + `` + `
${ansiToHtml(lastLines)}
` + + `` + `` ); } @@ -442,6 +443,7 @@ function buildSidebarHTML(session, currentSession) { `` + `` + `` @@ -1446,11 +1448,37 @@ async function createNewSession(name) { } } +/** + * Kill a tmux session by name via DELETE /api/sessions/{name}. + * Shows a confirmation dialog before killing. Refreshes the session list on success. + * @param {string} name - The session name to kill. + */ +function killSession(name) { + if (!confirm('Kill session "' + name + '"?')) return; + api('DELETE', '/api/sessions/' + name) + .then(function() { + showToast('Session \'' + name + '\' killed'); + pollSessions(); + }) + .catch(function(err) { + showToast('Failed to kill session: ' + (err.message || 'unknown error')); + }); +} + /** * Bind all static (once-only) event listeners for the app UI. * Called once after restoreState() resolves. */ function bindStaticEventListeners() { + // Delegated kill-session handler (tiles + sidebar items are re-rendered each poll) + document.addEventListener('click', function(e) { + var deleteBtn = e.target.closest && e.target.closest('.tile-delete, .sidebar-delete'); + if (!deleteBtn) return; + e.stopPropagation(); + var name = deleteBtn.dataset.session; + if (name) killSession(name); + }); + on($('back-btn'), 'click', closeSession); var newSessionBtn = $('new-session-btn'); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); @@ -1687,6 +1715,8 @@ if (typeof module !== 'undefined' && module.exports) { showNewSessionInput, showFabSessionInput, createNewSession, + // Kill session + killSession, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 4cc7a3f..b031f16 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -877,6 +877,63 @@ body { +/* ============================================================ + Delete (kill) session buttons — appear on hover + ============================================================ */ + +.tile-delete { + position: absolute; + top: 4px; + right: 6px; + background: none; + border: none; + color: var(--text-dim); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + opacity: 0; + transition: opacity 150ms ease; + z-index: 2; + line-height: 1; +} + +.session-tile:hover .tile-delete, +.session-tile:focus-within .tile-delete { + opacity: 1; +} + +.tile-delete:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + opacity: 1; +} + +.sidebar-delete { + background: none; + border: none; + color: var(--text-dim); + font-size: 13px; + cursor: pointer; + padding: 1px 5px; + border-radius: 3px; + opacity: 0; + transition: opacity 150ms ease; + flex-shrink: 0; + line-height: 1; +} + +.sidebar-item:hover .sidebar-delete, +.sidebar-item:focus-within .sidebar-delete { + opacity: 1; +} + +.sidebar-delete:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + opacity: 1; +} + /* ============================================================ Header actions + settings buttons ============================================================ */ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 5cc069c..8049a3f 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2382,6 +2382,20 @@ test('app.js source uses 500ms debounce for template input and references new_se assert.ok(source.includes('new_session_template'), 'must reference new_session_template setting key'); }); +test('buildTileHTML includes tile-delete button with data-session attribute', () => { + const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0, seen_at: null, last_fired_at: null } }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('tile-delete'), 'buildTileHTML must include tile-delete button class'); + assert.ok(html.includes('data-session="my-session"'), 'tile-delete button must have data-session attribute'); +}); + +test('buildSidebarHTML includes sidebar-delete button with data-session attribute', () => { + const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0, seen_at: null, last_fired_at: null } }; + const html = app.buildSidebarHTML(session, null); + assert.ok(html.includes('sidebar-delete'), 'buildSidebarHTML must include sidebar-delete button class'); + assert.ok(html.includes('data-session="my-session"'), 'sidebar-delete button must have data-session attribute'); +}); + test('createNewSession polls for session before auto-opening (not immediate setTimeout openSession)', () => { // The old behavior was: setTimeout(() => openSession(...), 500) immediately after POST. // The new behavior must use a polling interval to wait for the session to appear in diff --git a/muxplex/main.py b/muxplex/main.py index 7ad253d..7e30a70 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -404,6 +404,26 @@ async def delete_current_session() -> dict: return {"active_session": None} +@app.delete("/api/sessions/{name}") +async def delete_session(name: str) -> dict: + """Kill a tmux session by name. + + Runs `tmux kill-session -t {name}`. Returns {ok: True, name: name}. + 404 if session is not in the known session list (when non-empty). + Must be declared after DELETE /api/sessions/current so "current" routes correctly. + """ + known = get_session_list() + if known and name not in known: + raise HTTPException(status_code=404, detail=f"Session '{name}' not found") + + try: + await run_tmux("kill-session", "-t", name) + except RuntimeError: + raise HTTPException(status_code=500, detail=f"Failed to kill session '{name}'") + + return {"ok": True, "name": name} + + @app.post("/api/heartbeat") async def heartbeat(payload: HeartbeatPayload) -> dict: """Register or update a device heartbeat. diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index a6049de..e8fd7af 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1020,3 +1020,49 @@ def test_create_session_rejects_missing_name(client): """POST /api/sessions with missing name returns 422.""" response = client.post("/api/sessions", json={}) assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# DELETE /api/sessions/{name} +# --------------------------------------------------------------------------- + + +def test_delete_session_success(client, monkeypatch): + """DELETE /api/sessions/{name} returns 200 with {ok: True, name: name} when session exists.""" + from unittest.mock import AsyncMock + + monkeypatch.setattr( + "muxplex.main.get_session_list", lambda: ["my-session", "other"] + ) + monkeypatch.setattr("muxplex.main.run_tmux", AsyncMock(return_value="")) + + response = client.delete("/api/sessions/my-session") + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["name"] == "my-session" + + +def test_delete_session_calls_kill_session(client, monkeypatch): + """DELETE /api/sessions/{name} calls tmux kill-session -t {name}.""" + from unittest.mock import AsyncMock + + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"]) + mock_run_tmux = AsyncMock(return_value="") + monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux) + + client.delete("/api/sessions/my-session") + + assert mock_run_tmux.called + args = mock_run_tmux.call_args[0] + assert args[0] == "kill-session" + assert "-t" in args + assert "my-session" in args + + +def test_delete_session_not_found(client, monkeypatch): + """DELETE /api/sessions/{name} returns 404 when session is not in list.""" + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"]) + + response = client.delete("/api/sessions/nonexistent") + assert response.status_code == 404