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:
+83
-13
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user