perf: progressive loading for /resume conversation selector (#1462)
This commit is contained in:
@@ -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: conv,
|
conversation: {
|
||||||
|
id: "default",
|
||||||
|
agent_id: agentId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
} as Conversation,
|
||||||
previewLines: stats.previewLines,
|
previewLines: stats.previewLines,
|
||||||
lastActiveAt: stats.lastActiveAt,
|
lastActiveAt: stats.lastActiveAt,
|
||||||
messageCount: stats.messageCount,
|
messageCount: stats.messageCount,
|
||||||
|
enriched: true,
|
||||||
};
|
};
|
||||||
} catch {
|
})
|
||||||
// If we fail to fetch messages, show conversation anyway with -1 to indicate error
|
.catch(() => null)
|
||||||
return {
|
: Promise.resolve(null);
|
||||||
conversation: conv,
|
|
||||||
previewLines: [],
|
|
||||||
lastActiveAt: null,
|
|
||||||
messageCount: -1, // Unknown, don't filter out
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user