fix(app): keep rewind invalidation for find results after Next and Previous

The rewind marker of the result set was cleared in cancel(), which also
stops navigation work. Next and Previous call cancel() while keeping the
results, so after one step a remote rewind no longer invalidated them and
the selection could stay on a retired message. The marker now shares the
result set's lifetime and is cleared only when a new query discards it.
This commit is contained in:
Mohamed Boudra
2026-11-11 08:51:28 +01:00
parent 5a8383d237
commit 53fa9524cf
2 changed files with 75 additions and 2 deletions
@@ -281,6 +281,79 @@ it("stops a search still receiving pages when a rewind lands between them", asyn
}
});
it.each([
["Next", "next"],
["Previous", "previous"],
] as const)("reports a rewind after %s as a changed history", async (_label, step) => {
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model[step]();
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.updateHistory("epoch", [message(1)], 3);
expect(model.getSnapshot()).toMatchObject({
phase: "error",
failure: "historyChanged",
selectedItemId: null,
});
} finally {
model.close();
}
});
it("stops a pending Next when a rewind lands before it reveals", async () => {
let reveals = 0;
let revealSecond!: () => void;
const secondReveal = new Promise<void>((resolve) => {
revealSecond = resolve;
});
const model = new ChatFindModel({
async search() {
return response([
{ seq: 1, role: "assistant" },
{ seq: 2, role: "assistant" },
]);
},
async load() {},
async reveal() {
reveals += 1;
if (reveals > 1) await secondReveal;
return { count: 1, occurrence: 0 };
},
clear() {},
});
try {
model.updateHistory("epoch", [message(1), message(2)], null);
model.open();
model.setQuery("target");
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
model.next();
await expect.poll(() => reveals).toBe(2);
model.updateHistory("epoch", [message(1)], 3);
revealSecond();
await expect.poll(() => model.getSnapshot().failure).toBe("historyChanged");
expect(model.getSnapshot()).toMatchObject({ phase: "error", selectedItemId: null });
} finally {
model.close();
}
});
it("can load a search location before the initial chat history has arrived", async () => {
const model = new ChatFindModel({
async search() {
@@ -78,7 +78,7 @@ export class ChatFindModel {
private resultEpoch: string | null = null;
/** The latest rewind applied to the history. A rewind keeps the epoch and retires messages. */
private rewindSeq: number | null = null;
/** The latest rewind when the current search started; undefined while no search runs or holds results. */
/** The latest rewind when the current result set's search started; undefined until a search starts. */
private searchedRewindSeq: number | null | undefined = undefined;
private locations: Location[] = [];
private resolved = new Map<Location, Target>();
@@ -99,7 +99,6 @@ export class ChatFindModel {
this.listeners.forEach((listener) => listener());
}
private cancel() {
this.searchedRewindSeq = undefined;
this.abort.abort();
this.abort = new AbortController();
clearTimeout(this.timer);
@@ -118,6 +117,7 @@ export class ChatFindModel {
this.cancel();
this.locations = [];
this.resultEpoch = null;
this.searchedRewindSeq = undefined;
this.resolved.clear();
this.current = -1;
this.inMessage = { occurrence: 0, count: 0 };