From 7d5b92cdfbd1ba0d473c7907485d2a4b30344715 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 18:49:33 -0500 Subject: [PATCH] fix(desktop): bump session list order on user send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/sidebar/index.tsx | 7 +-- .../hooks/use-prompt-actions/submit.ts | 20 +++++- apps/desktop/src/store/session.test.ts | 63 +++++++++++++++++++ apps/desktop/src/store/session.ts | 53 ++++++++++++++-- 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 3f3fdd0ff39..0d82ef78725 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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] ) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 33b6ab4c33e..23142f4f0b7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -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. diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 1bb0d304300..f4dbac828ea 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -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', () => { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 340770936d0..d26840702dc 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -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(null) export const $gatewayState = atom('idle') export const $sessions = atom([])