Skip to content

Commit 2ffc9c1

Browse files
nearestnaborsclaude
andcommitted
Fix "Load more" pagination race condition
Server-side offset pagination was broken because re-fetching from X API between requests could return different filtered results. Switched to client-side pagination: server returns all conversations, UI handles incremental display via displayCount state. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7e8301c commit 2ffc9c1

2 files changed

Lines changed: 78 additions & 80 deletions

File tree

src/tools/conversations.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,23 @@ export async function xConversations(): Promise<unknown> {
503503
await arcadeClient.initiateAuth();
504504

505505
if (alreadyAuthorized) {
506-
// Auth is actually good - proceed with fetch
506+
// Auth is good but we don't have username cached - fetch it via X.WhoAmI
507+
debugLog("alreadyAuthorized=true but no username, fetching via WhoAmI");
508+
const whoami = await arcadeClient.getAuthenticatedUser();
509+
if (!whoami?.data?.username) {
510+
debugLog("X.WhoAmI failed to return username", whoami);
511+
return {
512+
content: [
513+
{
514+
type: "text",
515+
text: "Failed to get your X username. Please try x_auth_status first.",
516+
},
517+
],
518+
isError: true,
519+
};
520+
}
521+
522+
// Now proceed with fetch (username is now stored)
507523
const result = await fetchConversations();
508524
if (!result.success) {
509525
return result.content;
@@ -575,15 +591,19 @@ export async function xConversations(): Promise<unknown> {
575591

576592
/**
577593
* Tool: x_get_conversations (for UI only, hidden from model)
578-
* Returns full conversation data as JSON with pagination support
594+
* Returns full conversation data as JSON
595+
* Note: Returns ALL conversations - UI handles pagination client-side
596+
* to avoid race conditions with server-side offset-based pagination
579597
*/
580-
export async function xGetConversations(params?: {
598+
export async function xGetConversations(_params?: {
581599
limit?: number;
582600
offset?: number;
583601
}): Promise<unknown> {
602+
// Always fetch all conversations - UI handles pagination
603+
// This avoids race conditions where offset doesn't match current data
584604
const result = await fetchConversations({
585-
limit: params?.limit,
586-
offset: params?.offset,
605+
limit: 100,
606+
offset: 0,
587607
});
588608

589609
if (!result.success) {

ui-apps/conversation-list/app.tsx

Lines changed: 53 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ const PAGE_SIZE = 10;
2727
export function ConversationListApp() {
2828
const [appState, setAppState] = useState<AppState>("loading");
2929
const [authData, setAuthData] = useState<AuthRequiredResponse | null>(null);
30-
const [conversations, setConversations] = useState<ConversationItem[]>([]);
30+
// All conversations from server
31+
const [allConversations, setAllConversations] = useState<ConversationItem[]>(
32+
[]
33+
);
34+
// Number of conversations to display (for client-side pagination)
35+
const [displayCount, setDisplayCount] = useState(PAGE_SIZE);
3136
const [username, setUsername] = useState<string>("");
32-
const [totalCount, setTotalCount] = useState(0);
33-
const [hasMore, setHasMore] = useState(false);
34-
const [_offset, setOffset] = useState(0);
35-
const [isLoadingMore, setIsLoadingMore] = useState(false);
3637
const [errorMessage, setErrorMessage] = useState<string | null>(null);
3738

38-
const offsetRef = useRef(0);
3939
const hasFetchedInitial = useRef(false);
4040

4141
const { initialData, callTool, openLink, parseResult } = useMcpApp<
@@ -61,61 +61,44 @@ export function ConversationListApp() {
6161
[callTool, parseResult]
6262
);
6363

64-
// Fetch conversations
65-
const fetchConversations = useCallback(
66-
async (loadMore = false) => {
67-
try {
68-
const currentOffset = offsetRef.current;
69-
const newOffset = loadMore ? currentOffset + PAGE_SIZE : 0;
70-
const result = await callTool("x_get_conversations", {
71-
offset: newOffset,
72-
limit: PAGE_SIZE,
73-
});
64+
// Fetch all conversations from server
65+
const fetchConversations = useCallback(async () => {
66+
try {
67+
const result = await callTool("x_get_conversations", {});
7468

75-
const data = parseResult<
76-
ConversationsData | { error: boolean; message: string }
77-
>(result);
78-
if (typeof data === "string" || !data) {
79-
return;
80-
}
69+
const data = parseResult<
70+
ConversationsData | { error: boolean; message: string }
71+
>(result);
72+
if (typeof data === "string" || !data) {
73+
return;
74+
}
8175

82-
// Check for auth error - tool returns error when username not set
83-
if ("error" in data && data.error) {
84-
await handleAuthError((data as { message?: string }).message || "");
85-
return;
86-
}
76+
// Check for auth error - tool returns error when username not set
77+
if ("error" in data && data.error) {
78+
await handleAuthError((data as { message?: string }).message || "");
79+
return;
80+
}
8781

88-
if ("conversations" in data) {
89-
if (loadMore) {
90-
setConversations((prev) => [...prev, ...data.conversations]);
91-
} else {
92-
setConversations(data.conversations);
93-
}
94-
setUsername(data.username);
95-
setTotalCount(data.totalCount);
96-
setHasMore(data.hasMore);
97-
offsetRef.current = newOffset;
98-
setOffset(newOffset);
99-
setAppState("loaded");
100-
}
101-
} catch (error) {
102-
console.error("Failed to fetch conversations:", error);
103-
setErrorMessage(
104-
error instanceof Error
105-
? error.message
106-
: "Failed to load conversations"
107-
);
108-
setAppState("error");
82+
if ("conversations" in data) {
83+
setAllConversations(data.conversations);
84+
setUsername(data.username);
85+
setDisplayCount(PAGE_SIZE); // Reset display count on fresh fetch
86+
setAppState("loaded");
10987
}
110-
},
111-
[callTool, parseResult, handleAuthError]
112-
);
88+
} catch (error) {
89+
console.error("Failed to fetch conversations:", error);
90+
setErrorMessage(
91+
error instanceof Error ? error.message : "Failed to load conversations"
92+
);
93+
setAppState("error");
94+
}
95+
}, [callTool, parseResult, handleAuthError]);
11396

11497
// Auth poller
11598
const authPoller = useAuthPoller({
11699
callTool,
117100
openLink,
118-
onAuthComplete: () => fetchConversations(false),
101+
onAuthComplete: () => fetchConversations(),
119102
});
120103

121104
// Handle initial data
@@ -133,7 +116,7 @@ export function ConversationListApp() {
133116
// Not JSON - treat as text message, fetch conversations
134117
if (!hasFetchedInitial.current) {
135118
hasFetchedInitial.current = true;
136-
fetchConversations(false);
119+
fetchConversations();
137120
}
138121
return;
139122
}
@@ -147,15 +130,14 @@ export function ConversationListApp() {
147130
parsedData !== null &&
148131
"conversations" in parsedData
149132
) {
150-
setConversations(parsedData.conversations);
133+
setAllConversations(parsedData.conversations);
151134
setUsername(parsedData.username);
152-
setTotalCount(parsedData.totalCount);
153-
setHasMore(parsedData.hasMore);
135+
setDisplayCount(PAGE_SIZE);
154136
setAppState("loaded");
155137
} else if (!hasFetchedInitial.current) {
156138
// Unknown format - try fetching
157139
hasFetchedInitial.current = true;
158-
fetchConversations(false);
140+
fetchConversations();
159141
}
160142
}, [initialData, fetchConversations]);
161143

@@ -165,10 +147,9 @@ export function ConversationListApp() {
165147
}
166148
};
167149

168-
const handleLoadMore = async () => {
169-
setIsLoadingMore(true);
170-
await fetchConversations(true);
171-
setIsLoadingMore(false);
150+
// Client-side pagination - just show more items
151+
const handleLoadMore = () => {
152+
setDisplayCount((prev) => prev + PAGE_SIZE);
172153
};
173154

174155
const handleDismiss = async (tweetId: string, replyCount: number) => {
@@ -177,8 +158,7 @@ export function ConversationListApp() {
177158
tweet_id: tweetId,
178159
reply_count: replyCount,
179160
});
180-
setConversations((prev) => prev.filter((c) => c.tweet_id !== tweetId));
181-
setTotalCount((prev) => prev - 1);
161+
setAllConversations((prev) => prev.filter((c) => c.tweet_id !== tweetId));
182162
} catch (error) {
183163
console.error("Failed to dismiss:", error);
184164
}
@@ -207,7 +187,7 @@ export function ConversationListApp() {
207187
setAppState("loading");
208188
setErrorMessage(null);
209189
hasFetchedInitial.current = false;
210-
fetchConversations(false);
190+
fetchConversations();
211191
};
212192

213193
// Use StateContainer for loading, auth, and error states
@@ -226,9 +206,11 @@ export function ConversationListApp() {
226206
);
227207
}
228208

229-
// Render conversations
230-
const displayedCount = conversations.length;
231-
const remaining = totalCount - displayedCount;
209+
// Client-side pagination - slice all conversations to display count
210+
const visibleConversations = allConversations.slice(0, displayCount);
211+
const totalCount = allConversations.length;
212+
const hasMore = displayCount < totalCount;
213+
const remaining = totalCount - visibleConversations.length;
232214

233215
return (
234216
<div className="loaded container">
@@ -239,20 +221,20 @@ export function ConversationListApp() {
239221
<h2 className="heading-flush">Conversations</h2>
240222
<p className="text-muted" style={{ fontSize: "var(--font-size-sm)" }}>
241223
@{username} ·{" "}
242-
{displayedCount === 0 ? "All caught up!" : `${totalCount} total`}
224+
{totalCount === 0 ? "All caught up!" : `${totalCount} total`}
243225
</p>
244226
</div>
245227

246228
<div className="p-16">
247-
{displayedCount === 0 ? (
229+
{totalCount === 0 ? (
248230
<div className="empty-state">
249231
<div className="icon"></div>
250232
<h3>All caught up!</h3>
251233
<p>No conversations need your attention right now.</p>
252234
</div>
253235
) : (
254236
<>
255-
{conversations.map((conv) => (
237+
{visibleConversations.map((conv) => (
256238
<ConversationCard
257239
conversation={conv}
258240
key={conv.tweet_id}
@@ -265,11 +247,7 @@ export function ConversationListApp() {
265247

266248
{hasMore && (
267249
<div className="mt-4 text-center">
268-
<Button
269-
loading={isLoadingMore}
270-
onClick={handleLoadMore}
271-
variant="secondary"
272-
>
250+
<Button onClick={handleLoadMore} variant="secondary">
273251
Load more ({remaining} remaining)
274252
</Button>
275253
</div>

0 commit comments

Comments
 (0)