Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import {
type SessionRunStatus,
activeSessionIdAtomFamily,
autoTitleSessionAtomFamily,
bumpSessionActivityAtomFamily,
firstUserText,
persistSessionMessagesAtom,
sessionMessagesAtom,
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const SessionHistoryRow = ({
Ended
</span>
)}
{timeAgo(session.createdAt)}
{timeAgo(session.lastMessageAt ?? session.createdAt)}
</Text>
</div>
{!archived && nest.isAlive && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) && (
<span className="flex items-center gap-1.5 text-[11px] text-colorTextTertiary">
{session.ended && (
<span className="rounded bg-colorFillTertiary px-1 text-[10px] leading-4">
Ended
</span>
)}
{timeAgo(session.createdAt)}
{timeAgo(session.lastMessageAt ?? session.createdAt)}
</span>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
48 changes: 42 additions & 6 deletions web/oss/src/components/AgentChatSlice/state/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -145,20 +148,24 @@ export const isSessionHusk = (
messages: Record<string, UIMessage[]>,
): 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))
}),
)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
})
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
),
})
Comment thread
ashrafchowdury marked this conversation as resolved.
}),
)

export const setActiveSessionAtomFamily = atomFamily((key: string) =>
atom(null, (get, set, id: string) => {
set(activeByAppAtom, {...get(activeByAppAtom), [key]: id})
Expand Down
Loading