diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 53fb9688e..4d7385747 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -54,7 +54,8 @@ import { $sessions, $sessionsLoading, $sessionsTotal, - $workingSessionIds + $workingSessionIds, + sessionPinId } from '@/store/session' import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' @@ -73,7 +74,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ icon: props => , action: 'new-session' }, - { id: 'skills', label: 'Skills & Tools', icon: props => , route: SKILLS_ROUTE }, + { + id: 'skills', + label: 'Skills & Tools', + icon: props => , + route: SKILLS_ROUTE + }, { id: 'messaging', label: 'Messaging', icon: props => , route: MESSAGING_ROUTE }, { id: 'artifacts', label: 'Artifacts', icon: props => , route: ARTIFACTS_ROUTE } ] @@ -189,24 +195,45 @@ export function ChatSidebar({ const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions]) - const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions]) const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds]) - const visiblePinnedIds = useMemo( - () => pinnedSessionIds.filter(id => sessionsById.has(id)), - [pinnedSessionIds, sessionsById] - ) + // Index sessions by both their live id and their lineage-root id so a pin + // stored as the pre-compression root resolves to the live continuation tip. + const sessionByAnyId = useMemo(() => { + const map = new Map() - const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds]) + for (const s of sessions) { + map.set(s.id, s) - const pinnedSessions = useMemo( - () => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean), - [visiblePinnedIds, sessionsById] - ) + if (s._lineage_root_id && !map.has(s._lineage_root_id)) { + map.set(s._lineage_root_id, s) + } + } + + return map + }, [sessions]) + + const pinnedSessions = useMemo(() => { + const seen = new Set() + const out: SessionInfo[] = [] + + for (const pinId of pinnedSessionIds) { + const session = sessionByAnyId.get(pinId) + + if (session && !seen.has(session.id)) { + seen.add(session.id) + out.push(session) + } + } + + return out + }, [pinnedSessionIds, sessionByAnyId]) + + const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) const unpinnedAgentSessions = useMemo( - () => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)), - [sortedSessions, visiblePinnedIdSet] + () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), + [sortedSessions, pinnedRealIdSet] ) const agentSessions = useMemo( @@ -236,7 +263,10 @@ export function ChatSidebar({ return } - reorderPinnedSession(String(active.id), newIndex) + // Sortable ids are live session ids; the pinned store is keyed by durable + // (lineage-root) ids, so translate before reordering. + const dragged = sessionByAnyId.get(String(active.id)) + reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex) } const handleAgentDragEnd = ({ active, over }: DragEndEvent) => { @@ -536,7 +566,7 @@ function SidebarSessionsSection({ isWorking: workingSessionIdSet.has(session.id), onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(session.id), + onPin: () => onTogglePin(sessionPinId(session)), onResume: () => onResumeSession(session.id), session } diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index 2f6d8deb8..debcdd8cd 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -5,6 +5,7 @@ import { type FC, useCallback, useMemo, useRef } from 'react' import type { SessionInfo } from '@/hermes' import { cn } from '@/lib/utils' +import { sessionPinId } from '@/store/session' import { SidebarSessionRow } from './session-row' @@ -77,7 +78,7 @@ export const VirtualSessionList: FC = ({ isWorking: workingSessionIdSet.has(session.id), onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(session.id), + onPin: () => onTogglePin(sessionPinId(session)), onResume: () => onResumeSession(session.id) } diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ba513a2cf..58a7dd0e5 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -32,6 +32,8 @@ import { $freshDraftReady, $gatewayState, $selectedStoredSessionId, + $sessions, + sessionPinId, setAwaitingResponse, setBusy, setCurrentBranch, @@ -224,10 +226,14 @@ export function DesktopController() { return } - if ($pinnedSessionIds.get().includes(sessionId)) { - unpinSession(sessionId) + // Pin on the durable lineage-root id so the pin survives auto-compression. + const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) } else { - pinSession(sessionId) + pinSession(pinId) } }, []) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3e06027fc..6e41f3828 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -114,10 +114,11 @@ export class HermesGateway extends JsonRpcGatewayClient { export async function listSessions( limit = 40, minMessages = 0, - archived: 'exclude' | 'include' | 'only' = 'exclude' + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent' ): Promise { const result = await window.hermesDesktop.api({ - path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}` + path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}` }) return { diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts new file mode 100644 index 000000000..d9d2befb7 --- /dev/null +++ b/apps/desktop/src/store/session.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionPinId } from './session' + +const session = (over: Partial): SessionInfo => ({ + archived: false, + cwd: null, + ended_at: null, + id: 'live', + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 0, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 0, + title: null, + tool_call_count: 0, + ...over +}) + +describe('sessionPinId', () => { + it('uses the live id when there is no compression lineage', () => { + expect(sessionPinId(session({ id: 'abc' }))).toBe('abc') + }) + + it('uses the lineage root so a pin survives compression', () => { + // After auto-compression the entry surfaces under a fresh tip id but keeps + // the original root — pinning on the root keeps the pin stable. + expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root') + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 6cd26f6b9..cf2372f3d 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -16,6 +16,12 @@ function updateAtom(store: AppAtom, next: Updater) { store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next) } +/** Durable id for pinning. Auto-compression rotates a conversation's session + * id (root -> continuation tip), so pins keyed on the live id evaporate. The + * lineage root is stable across every compression, so we pin on that. */ +export const sessionPinId = (session: Pick): string => + session._lineage_root_id ?? session.id + export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 550b66deb..0fbad5f25 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -244,6 +244,10 @@ export interface SessionInfo { cwd?: null | string ended_at: null | number id: string + /** Original root id of a compression chain, when this entry is a projected + * continuation tip. Stable across compressions — used as the durable id for + * pins so a pinned conversation survives auto-compression. */ + _lineage_root_id?: null | string input_tokens: number is_active: boolean last_active: number diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 92d4119cf..4a3e20a6e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1363,6 +1363,7 @@ async def get_sessions( offset: int = 0, min_messages: int = 0, archived: str = "exclude", + order: str = "created", ): """List sessions. @@ -1370,12 +1371,22 @@ async def get_sessions( ``exclude`` (default) hides them, ``only`` returns just the archived ones (used by the desktop "Archived sessions" settings panel), and ``include`` returns both. + + ``order`` controls pagination order: ``created`` (default, by original + start time) or ``recent`` (by latest activity across the compression + chain). ``recent`` keeps a long-running conversation on the first page + after it auto-compresses into a fresh continuation id. """ if archived not in ("exclude", "only", "include"): raise HTTPException( status_code=400, detail="archived must be one of: exclude, only, include", ) + if order not in ("created", "recent"): + raise HTTPException( + status_code=400, + detail="order must be one of: created, recent", + ) try: from hermes_state import SessionDB db = SessionDB() @@ -1389,6 +1400,7 @@ async def get_sessions( min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only, + order_by_last_active=order == "recent", ) total = db.session_count( min_message_count=min_message_count, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8dd39fa1f..d994797e4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -293,6 +293,49 @@ class TestWebServerEndpoints: resp = self.client.get("/api/sessions?archived=bogus") assert resp.status_code == 400 + def test_get_sessions_rejects_unknown_order_value(self): + resp = self.client.get("/api/sessions?order=sideways") + assert resp.status_code == 400 + + def test_get_sessions_order_recent_surfaces_compression_tip(self): + """A long-running conversation that auto-compresses must stay on the + first page by recency, listed under its live continuation id.""" + import time as _time + + from hermes_state import SessionDB + + db = SessionDB() + try: + old = _time.time() - 86_400 + # Old conversation that later compresses into a fresh continuation. + # The continuation must start at/after the parent's ended_at to be + # recognised as a compression tip (not a sub-agent/branch). + db.create_session(session_id="root-old", source="cli") + db.append_message(session_id="root-old", role="user", content="kickoff") + db.end_session("root-old", "compression") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (old, old + 10, "root-old"), + ) + db.create_session(session_id="tip-new", source="cli", parent_session_id="root-old") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old + 10, "tip-new")) + db.append_message(session_id="tip-new", role="user", content="continued just now") + # A brand-new unrelated session started after the root but before now. + db.create_session(session_id="mid", source="cli") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (_time.time() - 3600, "mid")) + db.append_message(session_id="mid", role="user", content="hello") + db._conn.commit() + finally: + db.close() + + rows = self.client.get("/api/sessions?order=recent&limit=5").json()["sessions"] + ids = [r["id"] for r in rows] + # The compressed conversation surfaces under its live tip id... + assert "tip-new" in ids + # ...carrying the durable lineage root so the desktop can match pins. + tip = next(r for r in rows if r["id"] == "tip-new") + assert tip.get("_lineage_root_id") == "root-old" + def test_get_sessions_archived_is_boolean(self): from hermes_state import SessionDB