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:
brooklyn!
2026-06-01 20:41:34 -05:00
committed by GitHub
parent ddc22866a3
commit 85b65e29f0
26 changed files with 1000 additions and 77 deletions

View File

@ -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."""