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:
@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_session_db_row(session: dict) -> None:
|
||||
"""Idempotently persist the session's DB row on first real activity.
|
||||
|
||||
Called from prompt.submit so a row only exists once the user actually sends
|
||||
a message — abandoned drafts never leave an empty "Untitled" session behind.
|
||||
Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own
|
||||
lazy create) are no-ops. Captures cwd up front so workspace grouping works
|
||||
without waiting for a separate cwd update.
|
||||
"""
|
||||
key = session.get("session_key")
|
||||
if not key:
|
||||
return
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return
|
||||
try:
|
||||
db.create_session(
|
||||
key,
|
||||
source="tui",
|
||||
model=_resolve_model(),
|
||||
cwd=_session_cwd(session),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to persist desktop session row", exc_info=True)
|
||||
|
||||
|
||||
def _set_session_cwd(session: dict, cwd: str) -> str:
|
||||
resolved = os.path.abspath(os.path.expanduser(str(cwd)))
|
||||
if not os.path.isdir(resolved):
|
||||
@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict:
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
_register_session_cwd(_sessions[sid])
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
try:
|
||||
db.create_session(
|
||||
key,
|
||||
source="tui",
|
||||
model=_resolve_model(),
|
||||
cwd=_sessions[sid]["cwd"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to pre-create desktop session row", exc_info=True)
|
||||
# NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
|
||||
# launch (and every "New agent" / draft) opens a session here just to paint
|
||||
# the composer, so eagerly creating a row left an "Untitled" empty session
|
||||
# behind for every launch the user never typed into. The row is now created
|
||||
# lazily on the first prompt (see _ensure_session_db_row + prompt.submit),
|
||||
# and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too.
|
||||
|
||||
# Return the lightweight session immediately so Ink can paint the composer
|
||||
# + skeleton panel, then build the real AIAgent just after this response is
|
||||
@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict:
|
||||
session["last_active"] = time.time()
|
||||
_start_inflight_turn(session, text)
|
||||
|
||||
# Persist the DB row lazily, now that the user has actually sent a message.
|
||||
_ensure_session_db_row(session)
|
||||
_start_agent_build(sid, session)
|
||||
|
||||
def run_after_agent_ready() -> None:
|
||||
@ -3865,6 +3888,35 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"status": "streaming"})
|
||||
|
||||
|
||||
def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
|
||||
"""True if ``evt`` is owned by a *different* live session.
|
||||
|
||||
Background-process events carry the ``session_key`` of the session that
|
||||
started the process. Since all desktop sessions share one process-wide
|
||||
completion queue, each poller must skip events it doesn't own so a
|
||||
background job's completion surfaces in the session that launched it — not
|
||||
whichever poller happened to dequeue first. Orphaned events (owner gone)
|
||||
and global/system events (empty ``session_key``) return False so the
|
||||
current poller still handles them rather than losing them.
|
||||
"""
|
||||
evt_key = str(evt.get("session_key") or "")
|
||||
if not evt_key:
|
||||
return False
|
||||
if evt_key == str(session.get("session_key") or ""):
|
||||
return False
|
||||
try:
|
||||
snapshot = list(_sessions.values())
|
||||
except Exception:
|
||||
# If we can't safely enumerate live sessions, fail open so we don't
|
||||
# crash the poller thread or drop the event.
|
||||
return False
|
||||
|
||||
return any(
|
||||
s is not session and str(s.get("session_key") or "") == evt_key
|
||||
for s in snapshot
|
||||
)
|
||||
|
||||
|
||||
def _notification_poller_loop(
|
||||
stop_event: threading.Event, sid: str, session: dict
|
||||
) -> None:
|
||||
@ -3887,6 +3939,16 @@ def _notification_poller_loop(
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Multiple desktop sessions share this one process-wide queue. Only
|
||||
# consume events that belong to *this* session — otherwise a background
|
||||
# process started in session A would surface its completion in whichever
|
||||
# session's poller happened to wake first (Ben's "reported in a
|
||||
# different session" bug). Leave foreign events for their owner.
|
||||
if _notification_event_belongs_elsewhere(session, evt):
|
||||
process_registry.completion_queue.put(evt)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue
|
||||
@ -3917,12 +3979,17 @@ def _notification_poller_loop(
|
||||
session["running"] = False
|
||||
|
||||
# Drain any remaining events after stop signal (process all pending
|
||||
# before exiting so nothing is lost on shutdown).
|
||||
# before exiting so nothing is lost on shutdown). Events owned by other
|
||||
# live sessions are set aside and re-queued so their poller still sees them.
|
||||
deferred: list = []
|
||||
while not process_registry.completion_queue.empty():
|
||||
try:
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if _notification_event_belongs_elsewhere(session, evt):
|
||||
deferred.append(evt)
|
||||
continue
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue
|
||||
@ -3951,6 +4018,10 @@ def _notification_poller_loop(
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
|
||||
# Hand any other sessions' events back to the shared queue.
|
||||
for evt in deferred:
|
||||
process_registry.completion_queue.put(evt)
|
||||
|
||||
|
||||
def _start_notification_poller(sid: str, session: dict) -> threading.Event:
|
||||
"""Start the background notification poller for a TUI session."""
|
||||
|
||||
Reference in New Issue
Block a user