From b12a5a72b0fc2d860dd522dd6dac3395b801ec71 Mon Sep 17 00:00:00 2001 From: CCClelo <168716976+CCClelo@users.noreply.github.com> Date: Sun, 3 May 2026 10:19:11 +0000 Subject: [PATCH] Follow latest child session on dashboard resume --- hermes_cli/web_server.py | 93 ++++++++++++++++++++++++++++++++++++++ web/src/lib/api.ts | 12 +++++ web/src/pages/ChatPage.tsx | 32 +++++++++++-- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 773fe71807..a6af66bc9a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2173,6 +2173,83 @@ async def cancel_oauth_session(session_id: str, request: Request): # --------------------------------------------------------------------------- + +def _session_latest_descendant(session_id: str): + """Resolve a session id to the newest child leaf session. + + /model may create child sessions. Dashboard refresh should continue the + newest child instead of reopening the old parent. + """ + from hermes_state import SessionDB + + def row_get(row, key, index): + if isinstance(row, dict): + return row.get(key) + try: + return row[key] + except Exception: + try: + return row[index] + except Exception: + return None + + db = SessionDB() + try: + sid = db.resolve_session_id(session_id) + if not sid or not db.get_session(sid): + return None, [] + + conn = ( + getattr(db, "conn", None) + or getattr(db, "_conn", None) + or getattr(db, "connection", None) + or getattr(db, "_connection", None) + ) + + rows = [] + if conn is not None: + raw_rows = conn.execute( + "SELECT id, parent_session_id, started_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + }) + else: + rows = db.list_sessions_rich(limit=10000, offset=0) + + children = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid and parent: + children.setdefault(parent, []).append(row) + + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 + + current = sid + path = [sid] + seen = {sid} + + while children.get(current): + candidates = [r for r in children[current] if r.get("id") not in seen] + if not candidates: + break + candidates.sort(key=started, reverse=True) + current = candidates[0]["id"] + path.append(current) + seen.add(current) + + return current, path + finally: + db.close() + @app.get("/api/sessions/{session_id}") async def get_session_detail(session_id: str): from hermes_state import SessionDB @@ -2187,6 +2264,19 @@ async def get_session_detail(session_id: str): db.close() + +@app.get("/api/sessions/{session_id}/latest-descendant") +async def get_session_latest_descendant(session_id: str): + latest, path = _session_latest_descendant(session_id) + if not latest: + raise HTTPException(status_code=404, detail="Session not found") + return { + "requested_session_id": path[0] if path else session_id, + "session_id": latest, + "path": path, + "changed": bool(path and latest != path[0]), + } + @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str): from hermes_state import SessionDB @@ -2958,6 +3048,9 @@ def _resolve_chat_argv( env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") if resume: + latest_resume, _latest_path = _session_latest_descendant(resume) + if latest_resume: + resume = latest_resume env["HERMES_TUI_RESUME"] = resume if sidecar_url: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8fed709765..94d5b547d6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -49,6 +49,10 @@ export const api = { fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => fetchJSON(`/api/sessions/${encodeURIComponent(id)}/messages`), + getSessionLatestDescendant: (id: string) => + fetchJSON( + `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, + ), deleteSession: (id: string) => fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", @@ -373,6 +377,14 @@ export interface SessionInfo { input_tokens: number; output_tokens: number; preview: string | null; + parent_session_id?: string | null; +} + +export interface SessionLatestDescendantResponse { + requested_session_id: string; + session_id: string; + path: string[]; + changed: boolean; } export interface PaginatedSessions { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 79e84cf3b6..ab1dd0eacb 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -33,6 +33,7 @@ import { useSearchParams } from "react-router-dom"; import { ChatSidebar } from "@/components/ChatSidebar"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; +import { api } from "@/lib/api"; import { PluginSlot } from "@/plugins"; function buildWsUrl( @@ -111,7 +112,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // the moment `isActive` flips back to true (display:none → display:flex // collapses the host's box, so ResizeObserver never fires on return). const syncMetricsRef = useRef<(() => void) | null>(null); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); // Lazy-init: the missing-token check happens at construction so the effect // body doesn't have to setState (React 19's set-state-in-effect rule). const [banner, setBanner] = useState(() => @@ -153,8 +154,33 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // Sessions page relies on `/chat?resume=` changing at runtime, so we must // treat the current resume target as part of the PTY identity and rebuild the // terminal session when it changes. - const resumeId = searchParams.get("resume"); - const channel = useMemo(() => generateChannelId(), [resumeId]); + const resumeParam = searchParams.get("resume"); + const channel = useMemo(() => generateChannelId(), [resumeParam]); + + useEffect(() => { + if (!resumeParam) return; + + let cancelled = false; + + api + .getSessionLatestDescendant(resumeParam) + .then((res) => { + if (cancelled || !res.session_id || res.session_id === resumeParam) { + return; + } + + const next = new URLSearchParams(searchParams); + next.set("resume", res.session_id); + setSearchParams(next, { replace: true }); + }) + .catch(() => { + // Best-effort: old servers or missing sessions should not block chat. + }); + + return () => { + cancelled = true; + }; + }, [resumeParam, searchParams, setSearchParams]); useEffect(() => { const mql = window.matchMedia("(max-width: 1023px)");