Skip to content

Commit 7b83c49

Browse files
committed
fix(recall): refresh the open corpus view
Extraction can finish while the Recall browser remains mounted, leaving both visible entries and coverage metrics outdated with no way for the user to reconcile them. Use the shared refresh control so one bounded refresh reloads both views and keeps the page current on the standard interval.
1 parent 77f6399 commit 7b83c49

2 files changed

Lines changed: 122 additions & 7 deletions

File tree

frontend/src/lib/components/recall/RecallPage.svelte

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import { sessions } from "../../stores/sessions.svelte.js";
2626
import { ui } from "../../stores/ui.svelte.js";
2727
import { LatestRead } from "../../utils/latest-read.js";
28+
import RefreshControl from "../shared/RefreshControl.svelte";
2829
2930
const ENTRY_TYPES = [
3031
"fact",
@@ -50,6 +51,8 @@
5051
let entriesFailed = $state(false);
5152
let statusLoading = $state(true);
5253
let statusFailed = $state(false);
54+
let entriesUpdatedAt = $state<number | null>(null);
55+
let statusUpdatedAt = $state<number | null>(null);
5356
let search = $state("");
5457
let query = $state("");
5558
let project = $state("");
@@ -60,6 +63,11 @@
6063
let searchTimer: ReturnType<typeof setTimeout> | undefined;
6164
const entriesRead = new LatestRead();
6265
const statusRead = new LatestRead();
66+
const lastUpdatedAt = $derived(
67+
entriesUpdatedAt !== null && statusUpdatedAt !== null
68+
? Math.min(entriesUpdatedAt, statusUpdatedAt)
69+
: null,
70+
);
6371
6472
const projectOptions = $derived.by((): TypeaheadOption[] => [
6573
{
@@ -134,6 +142,7 @@
134142
: page.entries;
135143
nextCursor = page.nextCursor ?? "";
136144
resultCap = page.resultCap ?? 0;
145+
entriesUpdatedAt = Date.now();
137146
} catch (error) {
138147
if (isAbortError(error) || !entriesRead.isCurrent(signal)) return;
139148
if (appending && error instanceof ApiError && error.status === 409) {
@@ -159,6 +168,7 @@
159168
const next = await fetchRecallExtractionStatus(signal);
160169
if (!statusRead.isCurrent(signal)) return;
161170
status = next;
171+
statusUpdatedAt = Date.now();
162172
} catch (error) {
163173
if (isAbortError(error) || !statusRead.isCurrent(signal)) return;
164174
status = null;
@@ -175,6 +185,10 @@
175185
}, 250);
176186
}
177187
188+
async function refreshRecall() {
189+
await Promise.all([loadEntries(), loadStatus()]);
190+
}
191+
178192
function evidenceLabel(evidence: RecallEvidence): string {
179193
return m.session_recall_evidence_range({
180194
start: evidence.message_start_ordinal,
@@ -221,13 +235,21 @@
221235
<h2>{m.recall_page_title()}</h2>
222236
<p>{m.recall_page_subtitle()}</p>
223237
</div>
224-
{#if !entriesLoading && !entriesFailed}
225-
<span class="entry-count">
226-
{m.recall_page_entries_shown({
227-
countLabel: entries.length.toLocaleString(),
228-
})}
229-
</span>
230-
{/if}
238+
<div class="header-actions">
239+
{#if !entriesLoading && !entriesFailed}
240+
<span class="entry-count">
241+
{m.recall_page_entries_shown({
242+
countLabel: entries.length.toLocaleString(),
243+
})}
244+
</span>
245+
{/if}
246+
<RefreshControl
247+
{lastUpdatedAt}
248+
busy={entriesLoading || statusLoading}
249+
onRefresh={refreshRecall}
250+
label={m.shared_refresh()}
251+
/>
252+
</div>
231253
</header>
232254

233255
<Card level="default" padding="none" class="extraction-card">
@@ -513,6 +535,12 @@
513535
font-size: 10px;
514536
}
515537
538+
.header-actions {
539+
display: flex;
540+
align-items: center;
541+
gap: var(--space-4);
542+
}
543+
516544
.extraction-content {
517545
padding: 18px 20px;
518546
}

frontend/src/lib/components/recall/RecallPage.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,93 @@ describe("RecallPage", () => {
196196
);
197197
});
198198

199+
it("refreshes entries and extraction coverage while the page stays open", async () => {
200+
const defaultFetch = fetchMock as unknown as (
201+
input: RequestInfo | URL,
202+
) => Promise<Response>;
203+
let entryRequests = 0;
204+
let statusRequests = 0;
205+
fetchMock = vi.fn(async (input: RequestInfo | URL) => {
206+
const url = String(input);
207+
if (url.includes("/recall/entries?")) {
208+
entryRequests++;
209+
if (entryRequests > 1) {
210+
return new Response(JSON.stringify({
211+
entries: [{
212+
id: "recall-new",
213+
type: "fact",
214+
scope: "project",
215+
status: "accepted",
216+
review_state: "unreviewed_auto",
217+
title: "Newly distilled Recall entry",
218+
body: "This entry became available after the page opened.",
219+
source_session_id: "session-new",
220+
source_run_id: "generation-active",
221+
extractor_method: "turns-v1",
222+
transferable: false,
223+
provenance_ok: true,
224+
created_at: "2026-07-23T12:00:00Z",
225+
updated_at: "2026-07-23T12:00:00Z",
226+
evidence: [],
227+
}],
228+
trusted_only: false,
229+
}), {
230+
status: 200,
231+
headers: { "Content-Type": "application/json" },
232+
});
233+
}
234+
}
235+
if (url.includes("/recall/extraction/status")) {
236+
statusRequests++;
237+
if (statusRequests > 1) {
238+
return new Response(JSON.stringify({
239+
configured: true,
240+
fingerprint: "generation-active",
241+
source_runs: ["generation-active"],
242+
generations: [],
243+
stats: {
244+
pending: 1,
245+
partial: 0,
246+
done: 9,
247+
failed: 1,
248+
units_done: 20,
249+
units_total: 20,
250+
entries: 13,
251+
},
252+
eligible_backlog: 0,
253+
}), {
254+
status: 200,
255+
headers: { "Content-Type": "application/json" },
256+
});
257+
}
258+
}
259+
return defaultFetch(input);
260+
});
261+
vi.stubGlobal("fetch", fetchMock);
262+
component = mount(RecallPage, { target: document.body });
263+
await vi.waitFor(() => {
264+
expect(document.body.textContent).toContain(
265+
"Keep extraction passes bounded",
266+
);
267+
});
268+
269+
const refresh = document.querySelector<HTMLButtonElement>(
270+
'button[aria-label="Refresh"]',
271+
);
272+
expect(refresh).not.toBeNull();
273+
refresh!.click();
274+
275+
await vi.waitFor(() => {
276+
expect(document.body.textContent).toContain(
277+
"Newly distilled Recall entry",
278+
);
279+
expect(document.body.textContent).toContain("9 done");
280+
expect(document.body.textContent).toContain("13 entries");
281+
});
282+
expect(entryRequests).toBe(2);
283+
expect(statusRequests).toBe(2);
284+
});
285+
199286
it("loads the next cursor page and removes the truncation action", async () => {
200287
component = mount(RecallPage, { target: document.body });
201288

0 commit comments

Comments
 (0)