feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099)
* feat(desktop): session hygiene, archive, media streaming + connecting overlay
Address a batch of desktop feedback:
- Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB
row on every session.create (i.e. every launch/draft). Persist the row
lazily on first prompt instead, and hide message-less rows in the sidebar.
- Archive/hide sessions: new `archived` column + set_session_archived, web
API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item
in the sidebar, and an "Archived Chats" settings panel to restore/delete.
- Videos load via a streaming `hermes-media://` protocol instead of capped,
in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking.
- Background-process completions route to the session that launched them:
the completion event now carries session_key and each poller only consumes
its own.
- Sidebar: "Group by workspace" toggle is always visible; each workspace
group gets a "+" to start a session in that directory; "New agent"/"Agents"
relabeled to "New session"/"Sessions".
- New gateway connecting overlay (ascii decode → fade out) replacing the bare
skeleton/"starting gateway" state.
* fix(desktop): bail connecting overlay on boot error
The shownRef latch kept the connecting overlay mounted behind
BootFailureOverlay after a hard boot failure. Return null on boot.error
so the failure recovery surface fully owns the screen.
* fix(desktop): address Copilot review
- /api/sessions: validate `archived` (400 on unknown) and return `archived`
as a JSON boolean instead of SQLite's 0/1.
- PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no
updatable fields; stop conflating a no-op with "not found".
- hermes-media protocol: drop `bypassCSP` — streaming only needs
secure/standard/stream/supportFetchAPI.
- Sidebar workspace header: split the toggle and the "+" into sibling buttons
so we no longer nest interactive elements inside a <button>.
* fix(desktop): address Copilot re-review
- hermes-media protocol: restrict streaming to an audio/video extension
allowlist (415 otherwise) so it can't be used to read arbitrary local files.
- Connecting overlay: use z-[1200] instead of the non-standard z-1200 utility.
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
ddc22866a3
commit
85b65e29f0
@@ -187,11 +187,11 @@ class TestWebServerEndpoints:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0):
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
|
||||
captured["list"] = min_message_count
|
||||
return []
|
||||
|
||||
def session_count(self, min_message_count=0):
|
||||
def session_count(self, min_message_count=0, **kwargs):
|
||||
captured["count"] = min_message_count
|
||||
return 0
|
||||
|
||||
@@ -250,6 +250,76 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_archive_session_via_patch(self):
|
||||
"""PATCH archived=true soft-hides a session; archived=false restores it."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="arch-me", source="cli")
|
||||
db.append_message(session_id="arch-me", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
|
||||
# Hidden from the default list, surfaced by archived=only.
|
||||
listed = self.client.get("/api/sessions").json()
|
||||
assert all(s["id"] != "arch-me" for s in listed["sessions"])
|
||||
only = self.client.get("/api/sessions?archived=only").json()
|
||||
assert any(s["id"] == "arch-me" for s in only["sessions"])
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": False})
|
||||
assert resp.status_code == 200
|
||||
restored = self.client.get("/api/sessions").json()
|
||||
assert any(s["id"] == "arch-me" for s in restored["sessions"])
|
||||
|
||||
def test_patch_session_without_fields_is_400(self):
|
||||
"""An existing session + empty body is a bad request, not a 404."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="no-fields", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_archived_is_boolean(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="bool-arch", source="cli")
|
||||
db.append_message(session_id="bool-arch", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch")
|
||||
assert row["archived"] is False
|
||||
|
||||
def test_rename_response_omits_archived_when_not_set(self):
|
||||
"""Title-only PATCH keeps its legacy {ok, title} response shape."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="title-only", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"})
|
||||
assert resp.status_code == 200
|
||||
assert "archived" not in resp.json()
|
||||
|
||||
def test_audio_transcription_endpoint(self, monkeypatch):
|
||||
import tools.transcription_tools as transcription_tools
|
||||
|
||||
|
||||
@@ -3509,3 +3509,43 @@ class TestApplyWalProbe:
|
||||
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||||
"set-pragma must fire when probe returns 'delete'"
|
||||
)
|
||||
|
||||
|
||||
class TestSessionArchive:
|
||||
"""Soft-archiving hides a session from default listings without deleting it."""
|
||||
|
||||
def _seed(self, db, sid, *, archived=False):
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
|
||||
if archived:
|
||||
db.set_session_archived(sid, True)
|
||||
|
||||
def test_set_session_archived_roundtrip(self, db):
|
||||
self._seed(db, "s1")
|
||||
assert db.set_session_archived("s1", True) is True
|
||||
assert db.get_session("s1")["archived"] == 1
|
||||
assert db.set_session_archived("s1", False) is True
|
||||
assert db.get_session("s1")["archived"] == 0
|
||||
|
||||
def test_set_session_archived_missing_row(self, db):
|
||||
assert db.set_session_archived("nope", True) is False
|
||||
|
||||
def test_archived_excluded_by_default(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
ids = [s["id"] for s in db.list_sessions_rich()]
|
||||
assert ids == ["live"]
|
||||
assert db.session_count() == 1
|
||||
|
||||
def test_archived_only_and_include(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
|
||||
assert only == ["hidden"]
|
||||
assert db.session_count(archived_only=True) == 1
|
||||
|
||||
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
|
||||
assert both == {"live", "hidden"}
|
||||
assert db.session_count(include_archived=True) == 2
|
||||
|
||||
@@ -884,6 +884,73 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_notification_event_routing_by_session_key(monkeypatch):
|
||||
"""Background-process events surface only in the session that owns them."""
|
||||
mine = _session(session_key="mine")
|
||||
other = _session(session_key="other")
|
||||
monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
|
||||
|
||||
# My own event → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
|
||||
# Global/system event with no owner → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
|
||||
assert server._notification_event_belongs_elsewhere(mine, {}) is False
|
||||
# Owned by another *live* session → defer to that session's poller.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
|
||||
# Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
|
||||
|
||||
|
||||
def test_session_create_does_not_persist_empty_row(monkeypatch):
|
||||
"""session.create must NOT eagerly write a DB row.
|
||||
|
||||
Every TUI/desktop launch opens a session here just to paint the composer;
|
||||
eagerly creating a row left an empty "Untitled" session behind for every
|
||||
launch the user never typed into. The row is created lazily on first prompt.
|
||||
"""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, *args, **kwargs):
|
||||
created.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
server.threading,
|
||||
"Timer",
|
||||
lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
|
||||
)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.create", "params": {"cols": 80}}
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
try:
|
||||
assert resp["result"]["stored_session_id"]
|
||||
assert created == [], "session.create should not persist an empty DB row"
|
||||
finally:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
|
||||
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, cwd=None):
|
||||
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
|
||||
|
||||
assert created == [
|
||||
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
|
||||
]
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
|
||||
Reference in New Issue
Block a user