Skip to content

Commit ec42fcf

Browse files
jom-sqampcode-com
andcommitted
fix(client): prevent deleted threads from reappearing via API polling
After deletion, the Amp internal API has eventual consistency — deleted threads may still appear in listThreads results for a brief period. This caused threads to reappear in the UI after the 30s auto-refresh poll re-fetched the list, making deletion appear broken. Track recently-deleted thread IDs with a 2-minute grace period and filter them from all fetch results, preventing zombie threads from resurfacing during the API consistency lag. Co-authored-by: Amp <amp@ampcode.com>
1 parent 7d368fa commit ec42fcf

File tree

2 files changed

+66
-42
lines changed

2 files changed

+66
-42
lines changed

server/lib/threadCrud.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ export async function deleteThread(threadId: string): Promise<DeleteResult> {
139139
try {
140140
await runAmp(['threads', 'delete', threadId]);
141141
await cleanupThreadFiles(threadId);
142+
console.log(`[threads] Deleted ${threadId}`);
142143
return { success: true };
143144
} catch (e) {
144145
const error = e as Error;
146+
console.error(`[threads] Delete failed for ${threadId}: ${error.message}`);
145147
return { success: false, error: error.message };
146148
}
147149
}

src/hooks/useThreads.ts

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { apiGet, ApiError } from '../api/client';
55
// Auto-refresh interval (30 seconds)
66
const AUTO_REFRESH_INTERVAL_MS = 30000;
77

8+
// Grace period to suppress deleted threads from reappearing via API polling (2 minutes)
9+
const DELETE_GRACE_PERIOD_MS = 120000;
10+
811
export function useThreads() {
912
const [threads, setThreads] = useState<Thread[]>([]);
1013
const [loading, setLoading] = useState(true);
@@ -13,55 +16,73 @@ export function useThreads() {
1316
const [hasMore, setHasMore] = useState(false);
1417
const cursorRef = useRef<string | null>(null);
1518
const autoRefreshRef = useRef<number | null>(null);
19+
// Track recently deleted thread IDs to prevent them reappearing from API during eventual consistency lag
20+
const deletedIdsRef = useRef<Map<string, number>>(new Map());
1621

17-
const fetchThreads = useCallback(async (append = false) => {
18-
if (append) {
19-
setLoadingMore(true);
20-
} else {
21-
setLoading(true);
22+
const filterDeleted = useCallback((threadList: Thread[]): Thread[] => {
23+
const now = Date.now();
24+
const deleted = deletedIdsRef.current;
25+
// Prune expired entries
26+
for (const [id, expiry] of deleted) {
27+
if (now >= expiry) deleted.delete(id);
2228
}
23-
setError(null);
24-
25-
try {
26-
const cursor = append ? cursorRef.current : null;
27-
const data = await apiGet<ThreadsResult>(
28-
`/api/threads?limit=50${cursor ? `&cursor=${cursor}` : ''}`,
29-
);
29+
if (deleted.size === 0) return threadList;
30+
return threadList.filter((t) => !deleted.has(t.id));
31+
}, []);
3032

33+
const fetchThreads = useCallback(
34+
async (append = false) => {
3135
if (append) {
32-
setThreads((prev) => [...prev, ...data.threads]);
36+
setLoadingMore(true);
3337
} else {
34-
// Stabilize reference: skip setState if thread list hasn't meaningfully changed,
35-
// preventing downstream re-renders (e.g., useFilters label re-fetch) on every poll
36-
setThreads((prev) => {
37-
if (prev.length !== data.threads.length) return data.threads;
38-
const changed = prev.some((t, i) => {
39-
const next = data.threads[i];
40-
return (
41-
!next ||
42-
t.id !== next.id ||
43-
t.title !== next.title ||
44-
t.lastUpdated !== next.lastUpdated
45-
);
46-
});
47-
return changed ? data.threads : prev;
48-
});
38+
setLoading(true);
4939
}
50-
cursorRef.current = data.nextCursor;
51-
setHasMore(data.hasMore);
52-
} catch (err) {
53-
if (err instanceof ApiError) {
54-
console.error(`[useThreads] API error ${err.status}: ${err.message}`);
55-
setError(err.message);
56-
} else {
57-
console.error('[useThreads] Unexpected error:', err);
58-
setError(err instanceof Error ? err.message : 'Unknown error');
40+
setError(null);
41+
42+
try {
43+
const cursor = append ? cursorRef.current : null;
44+
const data = await apiGet<ThreadsResult>(
45+
`/api/threads?limit=50${cursor ? `&cursor=${cursor}` : ''}`,
46+
);
47+
48+
const filtered = filterDeleted(data.threads);
49+
50+
if (append) {
51+
setThreads((prev) => [...prev, ...filterDeleted(data.threads)]);
52+
} else {
53+
// Stabilize reference: skip setState if thread list hasn't meaningfully changed,
54+
// preventing downstream re-renders (e.g., useFilters label re-fetch) on every poll
55+
setThreads((prev) => {
56+
if (prev.length !== filtered.length) return filtered;
57+
const changed = prev.some((t, i) => {
58+
const next = filtered[i];
59+
return (
60+
!next ||
61+
t.id !== next.id ||
62+
t.title !== next.title ||
63+
t.lastUpdated !== next.lastUpdated
64+
);
65+
});
66+
return changed ? filtered : prev;
67+
});
68+
}
69+
cursorRef.current = data.nextCursor;
70+
setHasMore(data.hasMore);
71+
} catch (err) {
72+
if (err instanceof ApiError) {
73+
console.error(`[useThreads] API error ${err.status}: ${err.message}`);
74+
setError(err.message);
75+
} else {
76+
console.error('[useThreads] Unexpected error:', err);
77+
setError(err instanceof Error ? err.message : 'Unknown error');
78+
}
79+
} finally {
80+
setLoading(false);
81+
setLoadingMore(false);
5982
}
60-
} finally {
61-
setLoading(false);
62-
setLoadingMore(false);
63-
}
64-
}, []);
83+
},
84+
[filterDeleted],
85+
);
6586

6687
const loadMore = useCallback(() => {
6788
if (hasMore && !loadingMore) {
@@ -70,6 +91,7 @@ export function useThreads() {
7091
}, [hasMore, loadingMore, fetchThreads]);
7192

7293
const removeThread = useCallback((threadId: string) => {
94+
deletedIdsRef.current.set(threadId, Date.now() + DELETE_GRACE_PERIOD_MS);
7395
setThreads((prev) => prev.filter((t) => t.id !== threadId));
7496
}, []);
7597

0 commit comments

Comments
 (0)