Skip to content

Commit 126cf22

Browse files
jom-sqampcode-com
andauthored
fix(client): thread deletion fixes and load all available threads (#77)
* 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> * fix(client): load all available threads instead of first 50 The client was requesting only 50 threads from the server, but the server fetches up to 500 from the Amp API. This meant users with many threads only saw ~42 entries (50 minus stacked children), and deleting a thread appeared to have no effect on the count because a previously- hidden thread would backfill into the 50-thread window. Now requests limit=500 to match the server's Amp API cap, showing all available threads. The Amp API hard-caps at 500 — no pagination support — so this is the maximum we can display. Co-authored-by: Amp <amp@ampcode.com> * fix: paginate Amp API to load 1000 threads, stop premature refetch on bulk ops Three changes: 1. Paginate the Amp listThreads API using offset to fetch up to 1000 threads (the API caps at 500 per request). Previously only the first 500 were loaded. 2. Move refetch() in bulk delete/archive to only fire on failure. Previously refetch() ran unconditionally after bulk operations, which would immediately re-fetch the full list and undo the optimistic removal before the server finished processing. 3. Bump client request limit from 500 to 1000 to match. Co-authored-by: Amp <amp@ampcode.com> * fix(client): prevent thread backfill after deletion When deleting a thread with 1000+ total threads, the Amp API would return a new thread from beyond the original window to replace the deleted one, keeping the count unchanged despite the deletion. Fix: snapshot the set of known thread IDs before the first delete. While deletes are pending (2-minute grace period), only show threads from the known set — filtering both the deleted thread and any backfill. Genuinely new threads (created after the delete) are still allowed through. Co-authored-by: Amp <amp@ampcode.com> * feat: lazy-load threads with auto-pagination and total count display Start with 500 threads (1 API call). When user navigates to the last page, automatically load the next 500. Shows total count in the pagination bar: 'Showing 1-25 of 465 threads (1,000 total)'. Server changes: - getThreads uses offset-based pagination (replaces cursor-based) - listAllThreads accepts limit param, fetches only what's needed - hasMore based on whether full limit was returned - totalCount reflects actual loaded thread count Client changes: - useThreads starts with 500, loadMore adds 500 more - ThreadList auto-triggers loadMore when reaching last page - PaginationBar shows total count when it differs from loaded count Co-authored-by: Amp <amp@ampcode.com> * fix(ui): show '465+' instead of misleading total count Replace '(500 total)' / '(1,000 total)' with a simple '+' suffix on the thread count when more are available. Shows 'Showing 1-25 of 465+ threads' until all threads are loaded, then drops the '+'. Remove unused totalCount/serverTotalCount props. Co-authored-by: Amp <amp@ampcode.com> * feat(ui): show 'Loading more…' in pagination while fetching next batch Wire loadingMore state through the context chain to PaginationBar. When auto-load triggers on the last page, the page indicator shows 'Loading more…' instead of 'Page X of Y'. Co-authored-by: Amp <amp@ampcode.com> * fix: increase initial batch to 1000, fix lazy-load UX - Bump BATCH_SIZE from 500→1000 to capture all top-level threads upfront (handoff stacking collapses ~530 children into existing entries, so 500 extra threads only added ~8 visible entries) - Change auto-load from useEffect-based to click-based: load more only triggers when user clicks » or »» past the last page, not automatically on reaching it (prevents infinite loop) - Keep » and »» enabled on last page when hasMore is true so user can explicitly request more data Co-authored-by: Amp <amp@ampcode.com> * fix: remove lazy-load pagination, keep simple 1000-thread upfront load The lazy-load-on-paginate approach didn't work well because handoff stacking absorbs most additional threads into existing entries — loading 500 more threads only added ~8 visible entries, making it seem broken. Simplified to: load 1000 threads upfront (2 API calls), show '465+' to indicate more raw threads exist as stacked children. Standard pagination buttons with normal disabled behavior on last page. Co-authored-by: Amp <amp@ampcode.com> * fix(ui): remove misleading '+' from thread count The '+' suffix implied more threads could be loaded, but handoff stacking means all meaningful top-level threads are already visible with the 1000-thread fetch. Loading more only adds ~8 entries since extra threads are children absorbed into existing stacks. Now shows a clean '465 threads' count. Co-authored-by: Amp <amp@ampcode.com> * refactor: load all threads, remove anti-backfill complexity Load all threads from the Amp API (paginating internally at 500/call) instead of capping at 1000 with anti-backfill workarounds. After the bulk delete, thread count is ~496 which fits in a single API call. Removes: knownIdsRef, deletedIdsRef, lastDeleteTimeRef, pruneDeleted, loadMore, hasMore, totalCount, loadingMore — all the complexity added to work around partial loading. The hook is now ~90 lines instead of ~165. Delete now works simply: optimistic removal + server delete. The next poll shows the real state with no backfill because ALL threads are loaded. Co-authored-by: Amp <amp@ampcode.com> * fix(threads): replace broken API pagination with hybrid API+local loading The Amp internal API's offset parameter is silently ignored — it always returns the same threads regardless of offset value. The pagination loop in listThreads() never actually paginated. Changes: - listThreads(): single API call with limit=500 (API max), no loop - listAllThreads(): hybrid approach — API for up to 500 threads, then supplements with local thread files not in the API response - Local file scan only triggers when API returns its max (500), meaning there are likely more threads on disk - Remove unused limit/cursor params from getThreads() and route handler - Client no longer sends limit=10000 query param Co-authored-by: Amp <amp@ampcode.com> * fix(threads): always scan local files, not just when API returns 500 The conditional scan missed older threads when API returned <500 (e.g., after archiving/deleting). Local file scan is fast and ensures threads beyond the API's 500 limit are always visible. Co-authored-by: Amp <amp@ampcode.com> * fix(threads): delete local thread JSON file on delete to prevent zombie threads cleanupThreadFiles() deleted artifacts and SQLite records but left the thread JSON file in THREADS_DIR. After the hybrid loading change, the local file scan would rediscover deleted threads on the next poll. Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 7d368fa commit 126cf22

File tree

6 files changed

+152
-82
lines changed

6 files changed

+152
-82
lines changed

server/lib/amp-api.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,19 @@ interface ListThreadsResult {
175175
threads: AmpThreadSummary[];
176176
}
177177

178-
export async function listThreads(limit = 500): Promise<AmpThreadSummary[]> {
179-
const result = await callAmpInternalAPI<ListThreadsResult>('listThreads', { limit });
178+
/** Maximum threads the Amp API returns in a single call. */
179+
export const AMP_API_MAX = 500;
180+
181+
/**
182+
* Fetch threads from the Amp internal API.
183+
*
184+
* The API's `offset` parameter is silently ignored — it always returns the
185+
* same set of threads regardless of offset. So we make a single call with
186+
* limit capped at 500 (the API maximum).
187+
*/
188+
export async function listThreads(): Promise<AmpThreadSummary[]> {
189+
const result = await callAmpInternalAPI<ListThreadsResult>('listThreads', {
190+
limit: AMP_API_MAX,
191+
});
180192
return result.threads;
181193
}

server/lib/threadCrud.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,13 @@ import {
1414
type ThreadFile,
1515
} from './threadTypes.js';
1616

17-
interface GetThreadsOptions {
18-
limit?: number;
19-
cursor?: string | null;
20-
}
21-
22-
export async function getThreads({
23-
limit = 50,
24-
cursor = null,
25-
}: GetThreadsOptions = {}): Promise<ThreadsResult> {
17+
export async function getThreads(): Promise<ThreadsResult> {
2618
const allThreads = await listAllThreads();
2719

28-
// Apply cursor-based pagination (same contract as before)
29-
let startIndex = 0;
30-
if (cursor) {
31-
const cursorIndex = allThreads.findIndex((t) => t.id === cursor);
32-
if (cursorIndex !== -1) startIndex = cursorIndex + 1;
33-
}
34-
35-
const sliced = allThreads.slice(startIndex, startIndex + limit);
36-
const hasMore = startIndex + limit < allThreads.length;
37-
const lastThread = sliced[sliced.length - 1];
38-
3920
return {
40-
threads: sliced,
41-
nextCursor: lastThread && hasMore ? lastThread.id : null,
42-
hasMore,
21+
threads: allThreads,
22+
nextCursor: null,
23+
hasMore: false,
4324
};
4425
}
4526

@@ -121,6 +102,10 @@ export async function archiveThread(threadId: string): Promise<string> {
121102
}
122103

123104
async function cleanupThreadFiles(threadId: string): Promise<void> {
105+
// Delete the local thread JSON file so it doesn't resurface via local scan
106+
const threadFile = join(THREADS_DIR, `${threadId}.json`);
107+
await rm(threadFile, { force: true }).catch(() => {});
108+
124109
// Delete artifacts directory
125110
const threadArtifactsDir = join(ARTIFACTS_DIR, threadId);
126111
await rm(threadArtifactsDir, { recursive: true, force: true }).catch(() => {});
@@ -139,9 +124,11 @@ export async function deleteThread(threadId: string): Promise<DeleteResult> {
139124
try {
140125
await runAmp(['threads', 'delete', threadId]);
141126
await cleanupThreadFiles(threadId);
127+
console.log(`[threads] Deleted ${threadId}`);
142128
return { success: true };
143129
} catch (e) {
144130
const error = e as Error;
131+
console.error(`[threads] Delete failed for ${threadId}: ${error.message}`);
145132
return { success: false, error: error.message };
146133
}
147134
}

server/lib/threadProvider.ts

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,120 @@ function parseRepoFromUrl(url: string | undefined | null): string | null {
4747
return match?.[1] ?? url;
4848
}
4949

50-
// ── List all threads via API ────────────────────────────────────────────
50+
// ── List all threads via API + local supplement ─────────────────────────
5151

52+
/**
53+
* Fetch all threads using a hybrid approach:
54+
* 1. API call — returns up to 500 most-recent threads (the API's max)
55+
* 2. Local file scan — picks up any threads on disk not already in the API
56+
* response (handles >500 threads or threads not yet synced to server)
57+
*
58+
* Returns threads sorted by lastUpdatedDate descending.
59+
*/
5260
export async function listAllThreads(): Promise<Thread[]> {
53-
const summaries = await listThreads(500);
61+
const summaries = await listThreads();
5462
const threads = summaries.map(toThread);
63+
const apiIds = new Set(threads.map((t) => t.id));
64+
65+
// Always supplement with local thread files not in the API response.
66+
// The API returns at most 500 most-recent threads, so older threads
67+
// only appear via their local files on disk.
68+
const localExtras = await getLocalOnlyThreads(apiIds);
69+
threads.push(...localExtras);
5570

56-
// The listThreads API doesn't return relationship data.
57-
// Enrich from two sources:
58-
// 1. Local thread files (relationships[] array + handoff tool blocks)
59-
// 2. Shared thread ID prefix heuristic (handoff batches share first 4 UUID segments)
71+
// Enrich handoff relationships from local files + batch heuristic
6072
await enrichRelationships(threads);
6173
enrichBatchSiblings(threads);
6274

75+
// Sort by last updated descending (API threads are already sorted, but
76+
// local extras may interleave)
77+
threads.sort(
78+
(a, b) =>
79+
new Date(b.lastUpdatedDate || 0).getTime() - new Date(a.lastUpdatedDate || 0).getTime(),
80+
);
81+
6382
return threads;
6483
}
6584

85+
/**
86+
* Scan local thread files and return Thread objects for IDs not in `knownIds`.
87+
* Reads only the minimal fields needed (title, timestamps, env) — does NOT
88+
* parse full message content.
89+
*/
90+
async function getLocalOnlyThreads(knownIds: Set<string>): Promise<Thread[]> {
91+
let files: string[];
92+
try {
93+
files = await readdir(THREADS_DIR);
94+
} catch {
95+
return [];
96+
}
97+
98+
const extras: Thread[] = [];
99+
const jsonFiles = files.filter((f) => f.startsWith('T-') && f.endsWith('.json'));
100+
101+
await Promise.all(
102+
jsonFiles.map(async (file) => {
103+
const threadId = file.replace('.json', '');
104+
if (knownIds.has(threadId)) return;
105+
106+
try {
107+
const content = await readFile(join(THREADS_DIR, file), 'utf-8');
108+
const data = JSON.parse(content) as ThreadFile;
109+
extras.push(localFileToThread(threadId, data));
110+
} catch {
111+
// Skip unreadable files
112+
}
113+
}),
114+
);
115+
116+
return extras;
117+
}
118+
119+
/**
120+
* Convert a local ThreadFile into the shared Thread type.
121+
* Mirrors toThread() but works from the on-disk JSON shape.
122+
*/
123+
function localFileToThread(id: string, data: ThreadFile): Thread {
124+
const tree = data.env?.initial?.trees?.[0];
125+
const repoUrl = tree?.repository?.url;
126+
127+
let handoffParentId: string | null = null;
128+
const handoffChildIds: string[] = [];
129+
for (const rel of data.relationships || []) {
130+
if (isHandoffRelationship(rel)) {
131+
if (rel.role === 'child') {
132+
handoffParentId = rel.threadID;
133+
} else {
134+
handoffChildIds.push(rel.threadID);
135+
}
136+
}
137+
}
138+
139+
const createdMs = data.created || 0;
140+
const lastDate = new Date(createdMs);
141+
142+
return {
143+
id,
144+
title: data.title || id,
145+
lastUpdated: formatRelativeTime(lastDate),
146+
lastUpdatedDate: lastDate.toISOString(),
147+
visibility: normalizeVisibility(data.visibility ?? data.meta?.visibility),
148+
messages: (data.messages || []).length,
149+
workspace: tree?.displayName || null,
150+
workspacePath: parseFileUri(tree?.uri) || null,
151+
repo: parseRepoFromUrl(repoUrl),
152+
handoffParentId,
153+
handoffChildIds: [...new Set(handoffChildIds)],
154+
agentMode: data.agentMode,
155+
archived: false,
156+
model: undefined,
157+
cost: undefined,
158+
contextPercent: undefined,
159+
maxContextTokens: undefined,
160+
touchedFiles: undefined,
161+
};
162+
}
163+
66164
/**
67165
* Scan local thread files for handoff relationships and merge them into
68166
* the thread list. Checks two sources:

server/routes/threads.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ export async function handleThreadRoutes(
3434

3535
if (pathname === '/api/threads') {
3636
try {
37-
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
38-
const cursor = url.searchParams.get('cursor') || null;
39-
const result = await getThreads({ limit, cursor });
37+
const result = await getThreads();
4038

4139
// Trigger background actual cost fetching for visible threads
4240
enqueueCostFetch(result.threads.map((t: { id: string }) => t.id));

src/hooks/useThreadActions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ export function useThreadActions({
145145
} catch (err) {
146146
console.error('Bulk archive error:', err);
147147
showError('Some threads failed to archive');
148+
refetch();
148149
}
149-
refetch();
150150
},
151151
[refetch, removeThread, showError],
152152
);
@@ -171,12 +171,13 @@ export function useThreadActions({
171171
if (failures.length > 0) {
172172
console.error(`${failures.length} delete(s) failed`);
173173
showError(`${failures.length} thread(s) failed to delete`);
174+
refetch();
174175
}
175176
} catch (err) {
176177
console.error('Bulk delete error:', err);
177178
showError('Some threads failed to delete');
179+
refetch();
178180
}
179-
refetch();
180181
},
181182
[refetch, removeThread, showError],
182183
);

src/hooks/useThreads.ts

Lines changed: 20 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,47 +8,31 @@ const AUTO_REFRESH_INTERVAL_MS = 30000;
88
export function useThreads() {
99
const [threads, setThreads] = useState<Thread[]>([]);
1010
const [loading, setLoading] = useState(true);
11-
const [loadingMore, setLoadingMore] = useState(false);
1211
const [error, setError] = useState<string | null>(null);
13-
const [hasMore, setHasMore] = useState(false);
14-
const cursorRef = useRef<string | null>(null);
1512
const autoRefreshRef = useRef<number | null>(null);
1613

17-
const fetchThreads = useCallback(async (append = false) => {
18-
if (append) {
19-
setLoadingMore(true);
20-
} else {
21-
setLoading(true);
22-
}
14+
const fetchThreads = useCallback(async () => {
15+
setLoading(true);
2316
setError(null);
2417

2518
try {
26-
const cursor = append ? cursorRef.current : null;
27-
const data = await apiGet<ThreadsResult>(
28-
`/api/threads?limit=50${cursor ? `&cursor=${cursor}` : ''}`,
29-
);
19+
const data = await apiGet<ThreadsResult>('/api/threads');
3020

31-
if (append) {
32-
setThreads((prev) => [...prev, ...data.threads]);
33-
} 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;
21+
// Stabilize reference: skip setState if thread list hasn't meaningfully changed,
22+
// preventing downstream re-renders (e.g., useFilters label re-fetch) on every poll
23+
setThreads((prev) => {
24+
if (prev.length !== data.threads.length) return data.threads;
25+
const changed = prev.some((t, i) => {
26+
const next = data.threads[i];
27+
return (
28+
!next ||
29+
t.id !== next.id ||
30+
t.title !== next.title ||
31+
t.lastUpdated !== next.lastUpdated
32+
);
4833
});
49-
}
50-
cursorRef.current = data.nextCursor;
51-
setHasMore(data.hasMore);
34+
return changed ? data.threads : prev;
35+
});
5236
} catch (err) {
5337
if (err instanceof ApiError) {
5438
console.error(`[useThreads] API error ${err.status}: ${err.message}`);
@@ -59,16 +43,9 @@ export function useThreads() {
5943
}
6044
} finally {
6145
setLoading(false);
62-
setLoadingMore(false);
6346
}
6447
}, []);
6548

66-
const loadMore = useCallback(() => {
67-
if (hasMore && !loadingMore) {
68-
void fetchThreads(true);
69-
}
70-
}, [hasMore, loadingMore, fetchThreads]);
71-
7249
const removeThread = useCallback((threadId: string) => {
7350
setThreads((prev) => prev.filter((t) => t.id !== threadId));
7451
}, []);
@@ -79,7 +56,7 @@ export function useThreads() {
7956
const startPolling = () => {
8057
if (autoRefreshRef.current !== null) return;
8158
autoRefreshRef.current = window.setInterval(() => {
82-
void fetchThreads(false);
59+
void fetchThreads();
8360
}, AUTO_REFRESH_INTERVAL_MS);
8461
};
8562

@@ -94,7 +71,7 @@ export function useThreads() {
9471
if (document.hidden) {
9572
stopPolling();
9673
} else {
97-
void fetchThreads(false);
74+
void fetchThreads();
9875
startPolling();
9976
}
10077
};
@@ -111,11 +88,8 @@ export function useThreads() {
11188
return {
11289
threads,
11390
loading,
114-
loadingMore,
11591
error,
116-
hasMore,
117-
refetch: () => fetchThreads(false),
118-
loadMore,
92+
refetch: fetchThreads,
11993
removeThread,
12094
};
12195
}

0 commit comments

Comments
 (0)