fix(desktop): keep pinned + recent sessions visible across compression

Long-running sessions auto-compress: the gateway ends the original session
and surfaces the live continuation under a new id (list_sessions_rich projects
the root forward to its tip). Two symptoms fell out of the id rotation:

- A pinned session "vanished" — the pin is stored as the pre-compression root
  id, but the sidebar only matched on the live id, so it was filtered out.
  Pins now resolve on the durable lineage-root id (`_lineage_root_id`, already
  surfaced by the projection): the sidebar indexes sessions by both ids, pin/
  unpin and reorder operate on the durable id, and `sessionPinId()` is shared
  with the Cmd+P toggle. Existing pins keep working with no migration.

- A freshly-continued session was missing from the list until you ungrouped +
  "load 50 more" — the list paginated by original start time, so an old-but-
  active conversation sat past the first page. The desktop now requests
  `order=recent` (GET /api/sessions gains an `order` param backed by the
  existing recency CTE), surfacing live continuations on the first page.
This commit is contained in:
Brooklyn Nicholson
2026-06-02 07:12:05 -05:00
parent c10ccaaf51
commit de8bdf529d
9 changed files with 161 additions and 22 deletions

View File

@ -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 => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{ id: 'skills', label: 'Skills & Tools', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{
id: 'skills',
label: 'Skills & Tools',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE
},
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...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<string, SessionInfo>()
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<string>()
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
}

View File

@ -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<VirtualSessionListProps> = ({
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)
}

View File

@ -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)
}
}, [])

View File

@ -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<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
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 {

View File

@ -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>): 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')
})
})

View File

@ -16,6 +16,12 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
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<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])

View File

@ -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

View File

@ -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,

View File

@ -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