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:
Brooklyn Nicholson 2026-07-28 18:49:33 -05:00
parent 7bfbfa3e34
commit 7d5b92cdfb
4 changed files with 131 additions and 12 deletions

View file

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

View file

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

View file

@ -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', () => {

View file

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