perf: progressive loading for /resume conversation selector (#1462)

This commit is contained in:
Kian Jones
2026-03-23 14:56:46 -07:00
committed by GitHub
parent c7278b23a3
commit 1971362a23

View File

@@ -37,13 +37,15 @@ interface PreviewLine {
// Enriched conversation with message data // Enriched conversation with message data
interface EnrichedConversation { interface EnrichedConversation {
conversation: Conversation; conversation: Conversation;
previewLines: PreviewLine[]; // Last 1-3 user/assistant messages previewLines: PreviewLine[] | null; // null = not yet loaded
lastActiveAt: string | null; lastActiveAt: string | null; // Falls back to updated_at until enriched
messageCount: number; messageCount: number; // -1 = unknown/loading
enriched: boolean; // Whether message data has been fetched
} }
const DISPLAY_PAGE_SIZE = 3; const DISPLAY_PAGE_SIZE = 3;
const FETCH_PAGE_SIZE = 20; const FETCH_PAGE_SIZE = 20;
const ENRICH_MESSAGE_LIMIT = 20; // Same as original fetch limit
/** /**
* Format a relative time string from a date * Format a relative time string from a date
@@ -217,12 +219,52 @@ export function ConversationSelector({
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [enriching, setEnriching] = useState(false);
// Selection state // Selection state
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
// Load conversations and enrich with message data // Enrich a single conversation with message data, updating state in-place
const enrichConversation = useCallback(
async (client: Letta, convId: string) => {
try {
const messages = await client.conversations.messages.list(convId, {
limit: ENRICH_MESSAGE_LIMIT,
order: "desc",
});
const chronological = [...messages.getPaginatedItems()].reverse();
const stats = getMessageStats(chronological);
setConversations((prev) =>
prev.map((c) =>
c.conversation.id === convId
? {
...c,
previewLines: stats.previewLines,
lastActiveAt: stats.lastActiveAt || c.lastActiveAt,
messageCount: stats.messageCount,
enriched: true,
}
: c,
),
);
return stats.messageCount;
} catch {
// Mark as enriched even on error so we don't retry
setConversations((prev) =>
prev.map((c) =>
c.conversation.id === convId
? { ...c, previewLines: [], enriched: true }
: c,
),
);
return -1;
}
},
[],
);
// Load conversations — shows list immediately, enriches progressively
const loadConversations = useCallback( const loadConversations = useCallback(
async (afterCursor?: string | null) => { async (afterCursor?: string | null) => {
const isLoadingMore = !!afterCursor; const isLoadingMore = !!afterCursor;
@@ -237,38 +279,8 @@ export function ConversationSelector({
const client = clientRef.current || (await getClient()); const client = clientRef.current || (await getClient());
clientRef.current = client; clientRef.current = client;
// Fetch default conversation data (agent's primary message history) // Phase 1: Fetch conversation list + default messages in parallel
// Only fetch on initial load (not when paginating) const conversationListPromise = client.conversations.list({
let defaultConversation: EnrichedConversation | null = null;
if (!afterCursor) {
try {
const defaultMessages = await client.agents.messages.list(agentId, {
conversation_id: "default",
limit: 20,
order: "desc",
});
const defaultMsgItems = defaultMessages.getPaginatedItems();
if (defaultMsgItems.length > 0) {
const defaultStats = getMessageStats(
[...defaultMsgItems].reverse(),
);
defaultConversation = {
conversation: {
id: "default",
agent_id: agentId,
created_at: new Date().toISOString(),
} as Conversation,
previewLines: defaultStats.previewLines,
lastActiveAt: defaultStats.lastActiveAt,
messageCount: defaultStats.messageCount,
};
}
} catch {
// If we can't fetch default messages, just skip showing it
}
}
const result = await client.conversations.list({
agent_id: agentId, agent_id: agentId,
limit: FETCH_PAGE_SIZE, limit: FETCH_PAGE_SIZE,
...(afterCursor && { after: afterCursor }), ...(afterCursor && { after: afterCursor }),
@@ -276,73 +288,120 @@ export function ConversationSelector({
order_by: "last_run_completion", order_by: "last_run_completion",
}); });
// Enrich conversations with message data in parallel // Fetch default conversation in parallel (not sequentially before)
const enrichedConversations = await Promise.all( const defaultPromise: Promise<EnrichedConversation | null> =
result.map(async (conv) => { !afterCursor
try { ? client.agents.messages
// Fetch recent messages to get stats (desc order = newest first) .list(agentId, {
const messages = await client.conversations.messages.list( conversation_id: "default",
conv.id, limit: ENRICH_MESSAGE_LIMIT,
{ limit: 20, order: "desc" }, order: "desc",
); })
// Reverse to chronological for getMessageStats (expects oldest-first) .then((msgs) => {
const chronologicalMessages = [ const items = msgs.getPaginatedItems();
...messages.getPaginatedItems(), if (items.length === 0) return null;
].reverse(); const stats = getMessageStats([...items].reverse());
const stats = getMessageStats(chronologicalMessages); return {
return { conversation: {
conversation: conv, id: "default",
previewLines: stats.previewLines, agent_id: agentId,
lastActiveAt: stats.lastActiveAt, created_at: new Date().toISOString(),
messageCount: stats.messageCount, } as Conversation,
}; previewLines: stats.previewLines,
} catch { lastActiveAt: stats.lastActiveAt,
// If we fail to fetch messages, show conversation anyway with -1 to indicate error messageCount: stats.messageCount,
return { enriched: true,
conversation: conv, };
previewLines: [], })
lastActiveAt: null, .catch(() => null)
messageCount: -1, // Unknown, don't filter out : Promise.resolve(null);
};
}
}),
);
// Filter out empty conversations (messageCount === 0) const [result, defaultConversation] = await Promise.all([
// Keep conversations with messageCount > 0 or -1 (error/unknown) conversationListPromise,
const nonEmptyConversations = enrichedConversations.filter( defaultPromise,
(c) => c.messageCount !== 0, ]);
);
// Build unenriched conversation list using data already on the object
const unenrichedList: EnrichedConversation[] = result.map((conv) => ({
conversation: conv,
previewLines: null, // Not loaded yet
lastActiveAt: conv.updated_at ?? conv.created_at ?? null,
messageCount: -1, // Unknown until enriched
enriched: false,
}));
// Don't filter yet — we'll remove empties after enrichment confirms messageCount
const nonEmptyList = unenrichedList;
const newCursor = const newCursor =
result.length === FETCH_PAGE_SIZE result.length === FETCH_PAGE_SIZE
? (result[result.length - 1]?.id ?? null) ? (result[result.length - 1]?.id ?? null)
: null; : null;
// Phase 1 render: show conversation list immediately
if (isLoadingMore) { if (isLoadingMore) {
setConversations((prev) => [...prev, ...nonEmptyConversations]); setConversations((prev) => [...prev, ...nonEmptyList]);
} else { } else {
// Prepend default conversation to the list (if it has messages)
const allConversations = defaultConversation const allConversations = defaultConversation
? [defaultConversation, ...nonEmptyConversations] ? [defaultConversation, ...nonEmptyList]
: nonEmptyConversations; : nonEmptyList;
setConversations(allConversations); setConversations(allConversations);
setPage(0); setPage(0);
setSelectedIndex(0); setSelectedIndex(0);
} }
setCursor(newCursor); setCursor(newCursor);
setHasMore(newCursor !== null); setHasMore(newCursor !== null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err)); // Flip loading off now — list is visible, enrichment happens in background
} finally {
if (isLoadingMore) { if (isLoadingMore) {
setLoadingMore(false); setLoadingMore(false);
} else { } else {
setLoading(false); setLoading(false);
} }
// Phase 2: enrich visible page first, then rest in background
setEnriching(true);
const toEnrich = nonEmptyList.filter((c) => !c.enriched);
const firstPageItems = toEnrich.slice(0, DISPLAY_PAGE_SIZE);
const restItems = toEnrich.slice(DISPLAY_PAGE_SIZE);
// Enrich first page in parallel
const firstPageResults = await Promise.all(
firstPageItems.map((c) =>
enrichConversation(client, c.conversation.id),
),
);
// Remove conversations that turned out empty after enrichment
const emptyConvIds = new Set(
firstPageItems
.filter((_, i) => firstPageResults[i] === 0)
.map((c) => c.conversation.id),
);
if (emptyConvIds.size > 0) {
setConversations((prev) =>
prev.filter((c) => !emptyConvIds.has(c.conversation.id)),
);
}
// Enrich remaining conversations one by one in background
for (const item of restItems) {
const count = await enrichConversation(client, item.conversation.id);
if (count === 0) {
setConversations((prev) =>
prev.filter((c) => c.conversation.id !== item.conversation.id),
);
}
}
setEnriching(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
setLoadingMore(false);
} }
}, },
[agentId], [agentId, enrichConversation],
); );
// Initial load // Initial load
@@ -350,6 +409,23 @@ export function ConversationSelector({
loadConversations(); loadConversations();
}, [loadConversations]); }, [loadConversations]);
// Re-enrich when page changes (prioritize newly visible unenriched items)
useEffect(() => {
const client = clientRef.current;
if (!client || loading) return;
const visibleItems = conversations.slice(
page * DISPLAY_PAGE_SIZE,
(page + 1) * DISPLAY_PAGE_SIZE,
);
const unenriched = visibleItems.filter((c) => !c.enriched);
if (unenriched.length === 0) return;
for (const item of unenriched) {
enrichConversation(client, item.conversation.id);
}
}, [page, loading, conversations, enrichConversation]);
// Pagination calculations // Pagination calculations
const totalPages = Math.ceil(conversations.length / DISPLAY_PAGE_SIZE); const totalPages = Math.ceil(conversations.length / DISPLAY_PAGE_SIZE);
const startIndex = page * DISPLAY_PAGE_SIZE; const startIndex = page * DISPLAY_PAGE_SIZE;
@@ -441,7 +517,19 @@ export function ConversationSelector({
const bracket = <Text dimColor>{"⎿ "}</Text>; const bracket = <Text dimColor>{"⎿ "}</Text>;
const indent = " "; // Same width as "⎿ " for alignment const indent = " "; // Same width as "⎿ " for alignment
// Priority 2: Preview lines with emoji prefixes // Still loading message data
if (previewLines === null) {
return (
<Box flexDirection="row" marginLeft={2}>
{bracket}
<Text dimColor italic>
Loading preview...
</Text>
</Box>
);
}
// Has preview lines from messages
if (previewLines.length > 0) { if (previewLines.length > 0) {
return ( return (
<> <>
@@ -558,6 +646,15 @@ export function ConversationSelector({
</Box> </Box>
)} )}
{/* Enriching indicator */}
{!loading && enriching && (
<Box marginBottom={1}>
<Text dimColor italic>
Loading previews...
</Text>
</Box>
)}
{/* Empty state */} {/* Empty state */}
{!loading && !error && conversations.length === 0 && ( {!loading && !error && conversations.length === 0 && (
<Box flexDirection="column"> <Box flexDirection="column">