mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): bump session list order on user send
Recents were hard-sorted by started_at and only refreshed last_active after turn complete — so reviving an old thread stayed buried until the assistant finished. Stamp last_active on prompt seed, keep it monotonic across mid-turn refreshes, and sort agent recents by activity.
This commit is contained in:
parent
7bfbfa3e34
commit
7d5b92cdfb
4 changed files with 131 additions and 12 deletions
|
|
@ -384,11 +384,10 @@ export function ChatSidebar({
|
|||
[sessions, showAllProfiles, profileScope]
|
||||
)
|
||||
|
||||
// Agent session order is pinned to creation time (started_at), NOT activity —
|
||||
// a new message must never float a session to the top. Position only changes
|
||||
// for a brand-new session or an explicit manual drag (agentOrderIds).
|
||||
// Recents by activity (last_active || started_at). User send stamps
|
||||
// last_active immediately; manual drag order still wins below.
|
||||
const sortedSessions = useMemo(
|
||||
() => [...visibleSessions].sort((a, b) => (b.started_at || 0) - (a.started_at || 0)),
|
||||
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[visibleSessions]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@ import {
|
|||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $sessions, resolveComposerSessionKey, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
import {
|
||||
$sessions,
|
||||
resolveComposerSessionKey,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
touchSessionActivity
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
|
|
@ -293,7 +300,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
|
||||
// Idempotent optimistic insert — re-running with the resolved sessionId
|
||||
// after createBackendSessionForSend just overwrites with the same id.
|
||||
const seedOptimistic = (sid: string) =>
|
||||
const seedOptimistic = (sid: string) => {
|
||||
// Recents jump on send — not stream start, not turn resolve.
|
||||
const activity = bubbleText.trim() ? { preview: bubbleText.trim() } : undefined
|
||||
touchSessionActivity(sid, activity)
|
||||
|
||||
if (targetStoredSessionId && targetStoredSessionId !== sid) {
|
||||
touchSessionActivity(targetStoredSessionId, activity)
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sid,
|
||||
state => ({
|
||||
|
|
@ -312,6 +327,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
}),
|
||||
targetStoredSessionId
|
||||
)
|
||||
}
|
||||
|
||||
// After sync rewrites refs, refresh the optimistic message in place so the
|
||||
// transcript shows the resolved @file: ref rather than the local path.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
$connection,
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$unreadFinishedSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRememberedSessionId,
|
||||
|
|
@ -19,6 +20,8 @@ import {
|
|||
setCurrentCwd,
|
||||
setRememberedSessionId,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
touchSessionActivity,
|
||||
workspaceCwdForNewSession
|
||||
} from './session'
|
||||
import {
|
||||
|
|
@ -205,6 +208,66 @@ describe('mergeSessionPage', () => {
|
|||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
|
||||
})
|
||||
|
||||
it('never regresses last_active behind an optimistic user-send bump', () => {
|
||||
const previous = [session({ id: 'old', last_active: 9_000 })]
|
||||
const incoming = [session({ id: 'old', last_active: 100, message_count: 4 })]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, [])
|
||||
|
||||
expect(merged[0]?.last_active).toBe(9_000)
|
||||
expect(merged[0]?.message_count).toBe(4)
|
||||
})
|
||||
|
||||
it('carries an optimistic last_active across a compression tip rotation', () => {
|
||||
const previous = [session({ id: 'tip-4', _lineage_root_id: 'root', last_active: 9_000 })] as SessionInfo[]
|
||||
const incoming = [session({ id: 'tip-5', _lineage_root_id: 'root', last_active: 50 })] as SessionInfo[]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['tip-5'])
|
||||
expect(merged[0]?.last_active).toBe(9_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touchSessionActivity', () => {
|
||||
afterEach(() => {
|
||||
setSessions([])
|
||||
})
|
||||
|
||||
it('bumps last_active for a live id and a lineage-root pin target', () => {
|
||||
setSessions([
|
||||
session({ id: 'tip', _lineage_root_id: 'root', last_active: 10, preview: 'old' }),
|
||||
session({ id: 'other', last_active: 20 })
|
||||
] as SessionInfo[])
|
||||
|
||||
touchSessionActivity('root', { at: 99, preview: 'just sent' })
|
||||
|
||||
const rows = $sessions.get()
|
||||
const tip = rows.find(s => s.id === 'tip')
|
||||
const other = rows.find(s => s.id === 'other')
|
||||
|
||||
expect(tip?.last_active).toBe(99)
|
||||
expect(tip?.preview).toBe('just sent')
|
||||
expect(other?.last_active).toBe(20)
|
||||
})
|
||||
|
||||
it('is monotonic — a stale stamp does not pull the row down', () => {
|
||||
setSessions([session({ id: 'a', last_active: 50 })])
|
||||
|
||||
touchSessionActivity('a', { at: 10 })
|
||||
|
||||
expect($sessions.get()[0]?.last_active).toBe(50)
|
||||
})
|
||||
|
||||
it('preserves array identity when nothing matched', () => {
|
||||
const prev = [session({ id: 'a', last_active: 1 })]
|
||||
setSessions(prev)
|
||||
|
||||
touchSessionActivity('missing', { at: 99 })
|
||||
|
||||
expect($sessions.get()).toBe(prev)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
|
|
|
|||
|
|
@ -234,15 +234,20 @@ export function mergeSessionPage(
|
|||
// auto-titler. A real clear sets the local title null first, so this never
|
||||
// masks one.
|
||||
const prevById = new Map(previous.map(session => [session.id, session]))
|
||||
// Tip rotation changes the live id — carry activity/title across the lineage
|
||||
// root so a mid-turn refresh can't drop a touchSessionActivity bump.
|
||||
const prevByLineage = new Map(previous.map(session => [session._lineage_root_id ?? session.id, session]))
|
||||
|
||||
const merged = incoming.map(session => {
|
||||
if (session.title?.trim()) {
|
||||
return session
|
||||
}
|
||||
const prev = prevById.get(session.id) ?? prevByLineage.get(session._lineage_root_id ?? session.id)
|
||||
// User-send stamps last_active before the DB flushes the user row
|
||||
// (last_active = MAX(messages.timestamp)). Keep the fresher of the two.
|
||||
const last_active = Math.max(prev?.last_active ?? 0, session.last_active ?? 0)
|
||||
const title = session.title?.trim() ? session.title : prev?.title?.trim() ? prev.title : session.title
|
||||
|
||||
const carried = prevById.get(session.id)?.title?.trim()
|
||||
|
||||
return carried ? { ...session, title: carried } : session
|
||||
return last_active === session.last_active && title === session.title
|
||||
? session
|
||||
: { ...session, last_active, title }
|
||||
})
|
||||
|
||||
if (keep.size === 0) {
|
||||
|
|
@ -267,6 +272,42 @@ export function mergeSessionPage(
|
|||
return survivors.length ? [...survivors, ...merged] : merged
|
||||
}
|
||||
|
||||
/** Raise a session in recents on user send (before stream / turn resolve). */
|
||||
export function touchSessionActivity(
|
||||
sessionId: string | null | undefined,
|
||||
options?: { at?: number; preview?: string }
|
||||
): void {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const at = options?.at ?? Date.now() / 1000
|
||||
const preview = options?.preview?.trim().slice(0, 200) || undefined
|
||||
|
||||
setSessions(prev => {
|
||||
let changed = false
|
||||
const next = prev.map(session => {
|
||||
if (!sessionMatchesStoredId(session, id)) {
|
||||
return session
|
||||
}
|
||||
|
||||
const last_active = Math.max(session.last_active ?? 0, at)
|
||||
|
||||
if (last_active === session.last_active && (!preview || preview === session.preview)) {
|
||||
return session
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return preview ? { ...session, last_active, preview } : { ...session, last_active }
|
||||
})
|
||||
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom<ConnectionState>('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue