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