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:
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
36
apps/desktop/src/store/session.test.ts
Normal file
36
apps/desktop/src/store/session.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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[]>([])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user