fix(desktop): persist pins, reconnect after sleep, dedupe session search

Four related desktop session-management bugs:

- Pins lost until refresh: pinned sessions are joined against the
  paginated in-memory session list, so a pinned chat that aged off the
  most-recent page got evicted on the next refresh (every message.complete
  triggers one) and the Pinned section went empty. mergeWorkingSessions ->
  mergeSessionPage now also preserves pinned rows (matched by live id or
  lineage root). Pin id checks in the chat header, command center, and
  delete/archive are normalized to the durable sessionPinId so pins survive
  auto-compression.

- Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer
  WebSocket; nothing reconnected on wake so the composer stayed disabled.
  The gateway boot hook now auto-reconnects with backoff on close/error and
  on wake signals (powerMonitor resume/unlock-screen IPC, window online,
  visibilitychange). connect() gains an open timeout so a hung reconnect
  can't deadlock in 'connecting'. Composer placeholder distinguishes
  "Reconnecting to Hermes" from a cold start.

- Loses chats from itself: the same hard-replace that dropped pins also
  dropped loaded sessions; mergeSessionPage keeps them.

- Multiple copies/branches in search: /api/sessions/search deduped only by
  raw session_id, so compression segments and branches surfaced as separate
  hits. It now dedupes by lineage root and returns the live compression tip,
  matching the session_search tool's behavior.
This commit is contained in:
Brooklyn Nicholson
2026-06-03 12:39:31 -05:00
parent 84710995ef
commit 93228d5299
16 changed files with 443 additions and 58 deletions
+83 -13
View File
@@ -1529,7 +1529,18 @@ async def get_sessions(
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
"""Full-text search across session message content using FTS5."""
"""Full-text search across session message content using FTS5.
Results are deduped by *conversation lineage*, not by raw ``session_id``.
Auto-compression rotates a conversation onto a fresh session id (and leaves
the old segment's messages in the FTS index), and branches copy the
transcript into a new row so one logical chat owns many ``sessions`` rows
that all match the same query. Without lineage dedup the sidebar shows the
same conversation several times, which is the "multiple copies / branches"
navigation complaint. We collapse every match to its lineage root and
surface the live compression tip so clicking the result resumes the current
session.
"""
if not q or not q.strip():
return {"results": []}
try:
@@ -1547,20 +1558,79 @@ async def search_sessions(q: str = "", limit: int = 20):
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
matches = db.search_messages(query=prefix_query, limit=limit)
# Group by session_id — return unique sessions with their best snippet
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations even when several hits collapse onto one root.
fetch_limit = max(limit * 5, 50)
matches = db.search_messages(query=prefix_query, limit=fetch_limit)
# Walk parent_session_id to the lineage root, memoized so a chain of
# compression segments only costs one walk.
root_cache: dict = {}
def lineage_root(session_id: str) -> str:
if not session_id:
return session_id
if session_id in root_cache:
return root_cache[session_id]
chain = []
cur = session_id
visited = set()
root = session_id
while cur and cur not in visited:
visited.add(cur)
chain.append(cur)
if cur in root_cache:
root = root_cache[cur]
break
try:
s = db.get_session(cur)
except Exception:
s = None
if not s:
root = cur
break
parent = s.get("parent_session_id") if isinstance(s, dict) else None
if not parent:
root = cur
break
cur = parent
for node in chain:
root_cache[node] = root
return root
tip_cache: dict = {}
def lineage_tip(root_id: str) -> str:
if root_id in tip_cache:
return tip_cache[root_id]
tip = root_id
try:
resolved = db.get_compression_tip(root_id)
if resolved:
tip = resolved
except Exception:
pass
tip_cache[root_id] = tip
return tip
# Keep the best (first / most relevant) hit per lineage root.
seen: dict = {}
for m in matches:
sid = m["session_id"]
if sid not in seen:
seen[sid] = {
"session_id": sid,
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
}
raw_sid = m["session_id"]
root = lineage_root(raw_sid)
if root in seen:
continue
seen[root] = {
"session_id": lineage_tip(root),
"lineage_root": root,
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
}
if len(seen) >= limit:
break
return {"results": list(seen.values())}
finally:
db.close()