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})