diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
index 67886275eb..c681d84c20 100644
--- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
+++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx
@@ -149,6 +149,7 @@ import {
type SessionRunStatus,
activeSessionIdAtomFamily,
autoTitleSessionAtomFamily,
+ bumpSessionActivityAtomFamily,
firstUserText,
persistSessionMessagesAtom,
sessionMessagesAtom,
@@ -791,6 +792,7 @@ const AgentConversation = ({
// (cross-tab / cross-device) before it's opened. The atom no-ops once the session has a title,
// so this fires at most once and never overwrites an explicit rename.
const autoTitleSession = useSetAtom(autoTitleSessionAtomFamily(scopeKey))
+ const bumpSessionActivity = useSetAtom(bumpSessionActivityAtomFamily(scopeKey))
const firstUserMessage = useMemo(() => firstUserText(messages), [messages])
useEffect(() => {
if (firstUserMessage) autoTitleSession({id: sessionId, text: firstUserMessage})
@@ -1257,6 +1259,17 @@ const AgentConversation = ({
persistMessages({id: sessionId, messages})
}, [messages, status, sessionId, persistMessages])
+ // Stamp last-message time when a live turn finishes streaming (issue #5553: order history by
+ // last message). Gated on the streaming→settled transition so hydration/restore — which sets
+ // messages while `status` stays "ready" — never back-dates an old session to "now".
+ const prevStatusRef = useRef(status)
+ useEffect(() => {
+ if (prevStatusRef.current === "streaming" && status !== "streaming") {
+ bumpSessionActivity(sessionId)
+ }
+ prevStatusRef.current = status
+ }, [status, sessionId, bumpSessionActivity])
+
// Bound the in-message expand-state store: on settle, drop entries whose owning message is gone
// (rewound / evicted / closed). Live = every open session's persisted messages ∪ this active one.
// `store.get` reads without subscribing, so this never adds re-renders on the streaming hot path.
diff --git a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx
index cb5466d530..cfd410cb53 100644
--- a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx
+++ b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx
@@ -108,7 +108,7 @@ const SessionHistoryRow = ({
Ended
)}
- {timeAgo(session.createdAt)}
+ {timeAgo(session.lastMessageAt ?? session.createdAt)}
{!archived && nest.isAlive && (
diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx
index 1f68ff96a6..5c441d212a 100644
--- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx
+++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx
@@ -173,14 +173,14 @@ const SessionRailRow = memo(function SessionRailRow({
active ? "text-colorText" : "text-colorTextSecondary",
)}
/>
- {(session.ended || timeAgo(session.createdAt)) && (
+ {(session.ended || timeAgo(session.lastMessageAt ?? session.createdAt)) && (
{session.ended && (
Ended
)}
- {timeAgo(session.createdAt)}
+ {timeAgo(session.lastMessageAt ?? session.createdAt)}
)}
diff --git a/web/oss/src/components/AgentChatSlice/state/projectSessions.ts b/web/oss/src/components/AgentChatSlice/state/projectSessions.ts
index 3ae0bc220b..c5ba06b222 100644
--- a/web/oss/src/components/AgentChatSlice/state/projectSessions.ts
+++ b/web/oss/src/components/AgentChatSlice/state/projectSessions.ts
@@ -74,6 +74,8 @@ const toSummary = (s: SessionStream): ServerSessionSummary => ({
id: s.session_id,
title: s.name?.trim() ? s.name : undefined,
createdAt: s.created_at ? Date.parse(s.created_at) || undefined : undefined,
+ // Heartbeat `updated_at` (falls back to created_at) = last-activity time for ordering history.
+ lastMessageAt: activity(s) || undefined,
// A soft-deleted stream row is a killed/ended session (still resumable) — the list includes it
// via include_ended; the sidebar shows it muted.
ended: Boolean(s.deleted_at),
diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts
index c150e90384..ca270857ae 100644
--- a/web/oss/src/components/AgentChatSlice/state/sessions.ts
+++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts
@@ -42,8 +42,11 @@ export interface AgentChatSession {
id: string
/** User-set title. When empty, the UI falls back to the first user message / "Chat N". */
title?: string
- /** Creation time (ms epoch). Orders the history picker; absent on pre-upgrade sessions. */
+ /** Creation time (ms epoch). Fallback ordering key; absent on pre-upgrade sessions. */
createdAt?: number
+ /** Last-message time (ms epoch): server heartbeat `updated_at`, or a local turn settling.
+ * Primary ordering key for the history picker so recently-active chats float to the top. */
+ lastMessageAt?: number
/** Set once the server list confirms this session exists. Distinguishes a remotely-deleted
* session (was true, now absent from the server → drop) from a purely-local optimistic one. */
serverKnown?: boolean
@@ -145,20 +148,24 @@ export const isSessionHusk = (
messages: Record,
): boolean => !session.serverKnown && !session.title?.trim() && !messages[session.id]?.length
-/** Active (non-archived) sessions for a scope, newest first. Backs the main history picker. */
+/** Ordering key: most-recent message first, falling back to creation time, then 0 (pre-upgrade
+ * sessions with neither sort last, preserving their order). */
+const sessionActivity = (s: AgentChatSession): number => s.lastMessageAt ?? s.createdAt ?? 0
+
+/** Active (non-archived) sessions for a scope, most-recently-active first. Backs the main history
+ * picker (see issue #5553: order by last message, not creation). */
export const sessionHistoryAtomFamily = atomFamily((key: string) =>
atom((get) => {
const list = (get(sessionsByAppAtom)[key] ?? []).filter((s) => !s.archived)
- // Newest first; pre-upgrade sessions (no createdAt) sort last, preserving their order.
- return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return [...list].sort((a, b) => sessionActivity(b) - sessionActivity(a))
}),
)
-/** Archived sessions for a scope, newest first. Backs the archived view. */
+/** Archived sessions for a scope, most-recently-active first. Backs the archived view. */
export const archivedSessionHistoryAtomFamily = atomFamily((key: string) =>
atom((get) => {
const list = (get(sessionsByAppAtom)[key] ?? []).filter((s) => s.archived)
- return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return [...list].sort((a, b) => sessionActivity(b) - sessionActivity(a))
}),
)
@@ -374,6 +381,8 @@ export interface ServerSessionSummary {
id: string
title?: string
createdAt?: number
+ /** Server heartbeat `updated_at` (ms epoch) — the last-activity time used to order history. */
+ lastMessageAt?: number
ended?: boolean
archived?: boolean
}
@@ -405,6 +414,10 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) =>
serverKnown: true,
title: s.title?.trim() ? s.title : remote.title,
createdAt: s.createdAt ?? remote.createdAt,
+ // Keep the freshest activity time: a local turn just settled may lead the
+ // server heartbeat, and vice-versa across devices.
+ lastMessageAt:
+ Math.max(s.lastMessageAt ?? 0, remote.lastMessageAt ?? 0) || undefined,
ended: remote.ended,
archived: remote.archived,
})
@@ -420,6 +433,7 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) =>
id: s.id,
title: s.title,
createdAt: s.createdAt,
+ lastMessageAt: s.lastMessageAt,
serverKnown: true,
ended: s.ended,
archived: s.archived,
@@ -435,6 +449,7 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) =>
e.id !== m.id ||
e.title !== m.title ||
e.createdAt !== m.createdAt ||
+ e.lastMessageAt !== m.lastMessageAt ||
e.serverKnown !== m.serverKnown ||
e.ended !== m.ended ||
e.archived !== m.archived
@@ -597,6 +612,27 @@ export const autoTitleSessionAtomFamily = atomFamily((key: string) =>
}),
)
+/** Stamp `now` as a session's last-message time (a local turn just settled), so the history picker
+ * floats it to the top immediately — ahead of the next server heartbeat/reconcile. No-op if the id
+ * isn't in this scope's history yet. */
+export const bumpSessionActivityAtomFamily = atomFamily((key: string) =>
+ atom(null, (get, set, id: string) => {
+ const all = get(sessionsByAppAtom)
+ const list = all[key] ?? []
+ if (!list.some((s) => s.id === id)) return
+ // Keep the freshest time: a server heartbeat may already lead the local clock (skew).
+ const now = Date.now()
+ set(sessionsByAppAtom, {
+ ...all,
+ [key]: list.map((s) =>
+ s.id === id
+ ? {...s, lastMessageAt: Math.max(s.lastMessageAt ?? 0, now) || undefined}
+ : s,
+ ),
+ })
+ }),
+)
+
export const setActiveSessionAtomFamily = atomFamily((key: string) =>
atom(null, (get, set, id: string) => {
set(activeByAppAtom, {...get(activeByAppAtom), [key]: id})