mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
Follow latest child session on dashboard resume
This commit is contained in:
parent
e9685a5cf7
commit
b12a5a72b0
3 changed files with 134 additions and 3 deletions
|
|
@ -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}")
|
@app.get("/api/sessions/{session_id}")
|
||||||
async def get_session_detail(session_id: str):
|
async def get_session_detail(session_id: str):
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
|
|
@ -2187,6 +2264,19 @@ async def get_session_detail(session_id: str):
|
||||||
db.close()
|
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")
|
@app.get("/api/sessions/{session_id}/messages")
|
||||||
async def get_session_messages(session_id: str):
|
async def get_session_messages(session_id: str):
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
|
|
@ -2958,6 +3048,9 @@ def _resolve_chat_argv(
|
||||||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||||
|
|
||||||
if resume:
|
if resume:
|
||||||
|
latest_resume, _latest_path = _session_latest_descendant(resume)
|
||||||
|
if latest_resume:
|
||||||
|
resume = latest_resume
|
||||||
env["HERMES_TUI_RESUME"] = resume
|
env["HERMES_TUI_RESUME"] = resume
|
||||||
|
|
||||||
if sidecar_url:
|
if sidecar_url:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ export const api = {
|
||||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||||
getSessionMessages: (id: string) =>
|
getSessionMessages: (id: string) =>
|
||||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||||
|
getSessionLatestDescendant: (id: string) =>
|
||||||
|
fetchJSON<SessionLatestDescendantResponse>(
|
||||||
|
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
|
||||||
|
),
|
||||||
deleteSession: (id: string) =>
|
deleteSession: (id: string) =>
|
||||||
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|
@ -373,6 +377,14 @@ export interface SessionInfo {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
preview: string | null;
|
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 {
|
export interface PaginatedSessions {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { useSearchParams } from "react-router-dom";
|
||||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import { PluginSlot } from "@/plugins";
|
import { PluginSlot } from "@/plugins";
|
||||||
|
|
||||||
function buildWsUrl(
|
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
|
// the moment `isActive` flips back to true (display:none → display:flex
|
||||||
// collapses the host's box, so ResizeObserver never fires on return).
|
// collapses the host's box, so ResizeObserver never fires on return).
|
||||||
const syncMetricsRef = useRef<(() => void) | null>(null);
|
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
|
// 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).
|
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||||
const [banner, setBanner] = useState<string | null>(() =>
|
const [banner, setBanner] = useState<string | null>(() =>
|
||||||
|
|
@ -153,8 +154,33 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||||
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
|
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
|
||||||
// treat the current resume target as part of the PTY identity and rebuild the
|
// treat the current resume target as part of the PTY identity and rebuild the
|
||||||
// terminal session when it changes.
|
// terminal session when it changes.
|
||||||
const resumeId = searchParams.get("resume");
|
const resumeParam = searchParams.get("resume");
|
||||||
const channel = useMemo(() => generateChannelId(), [resumeId]);
|
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(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia("(max-width: 1023px)");
|
const mql = window.matchMedia("(max-width: 1023px)");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue