Keep older chat history and image previews stable (#2490)

* fix(app): keep timeline history and previews stable

Treat persisted timeline replicas as display-only so authoritative pagination always comes from the daemon. Require renewed user intent between older-history pages unless the viewport genuinely remains at the history edge.

Model assistant image acquisition as loading, loaded, or failed so recreating a preview URL cannot be rendered as an error.

* fix(app): keep file image previews current

* fix(app): stabilize history pagination edges

* fix(app): recover transient timeline loads

* fix(app): retain assistant image previews

* Stabilize history pagination lifecycle

* Stabilize history settlement and image previews

* Retry file images after reconnect

* Keep active previews and pagination requests alive

* Replace image hook tests with typed ports

* Integrate timeline hydration with submission authority

* Harden pre-hydration timeline reconciliation

* Make rewind E2E use real scroll intent

* Preserve live timeline state through hydration

* Keep mounted image attachments retained

* Protect preview persistence and lifecycle hydration

* Preserve idless live assistant continuations

* Preserve timeline rows across delayed hydration

* test(app): cover real assistant image files

* Preserve assistant tool ordering during hydration
This commit is contained in:
Mohamed Boudra
2026-07-28 22:29:03 +02:00
committed by GitHub
parent bbf3d0f9cc
commit 504b687f89
55 changed files with 4550 additions and 722 deletions
+77 -11
View File
@@ -1,5 +1,40 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
import { getAttachmentStore } from "@/attachments/store";
import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types";
const activePersistence = new Set<Promise<AttachmentMetadata>>();
const persistedDuringGarbageCollection = new Set<string>();
let pendingGarbageCollections = 0;
let garbageCollectionTail: Promise<void> = Promise.resolve();
let persistenceBarrier: Promise<void> | null = null;
let releasePersistenceBarrier: (() => void) | null = null;
async function waitForPersistenceBarrier(): Promise<void> {
const barrier = persistenceBarrier;
if (!barrier) {
return;
}
await barrier;
await waitForPersistenceBarrier();
}
async function persistAttachment(input: SaveAttachmentInput): Promise<AttachmentMetadata> {
await waitForPersistenceBarrier();
const pending = (async () => {
const store = await getAttachmentStore();
const attachment = await store.save(input);
if (pendingGarbageCollections > 0) {
persistedDuringGarbageCollection.add(attachment.id);
}
return attachment;
})();
activePersistence.add(pending);
try {
return await pending;
} finally {
activePersistence.delete(pending);
}
}
export async function persistAttachmentFromBlob(input: {
blob: Blob;
@@ -7,8 +42,7 @@ export async function persistAttachmentFromBlob(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -22,8 +56,7 @@ export async function persistAttachmentFromDataUrl(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -37,8 +70,7 @@ export async function persistAttachmentFromBytes(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -52,8 +84,7 @@ export async function persistAttachmentFromFileUri(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
const store = await getAttachmentStore();
return await store.save({
return await persistAttachment({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -133,6 +164,41 @@ export async function deleteAttachments(
export async function garbageCollectAttachments(input: {
referencedIds: ReadonlySet<string>;
}): Promise<void> {
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds: input.referencedIds });
pendingGarbageCollections += 1;
if (!persistenceBarrier) {
persistenceBarrier = new Promise<void>((resolve) => {
releasePersistenceBarrier = resolve;
});
}
const previousGarbageCollection = garbageCollectionTail;
const currentGarbageCollection = (async () => {
await previousGarbageCollection;
while (activePersistence.size > 0) {
await Promise.allSettled(activePersistence);
}
const referencedIds = new Set(input.referencedIds);
for (const id of collectRetainedAttachmentIds()) {
referencedIds.add(id);
}
for (const id of persistedDuringGarbageCollection) {
referencedIds.add(id);
}
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds });
})();
garbageCollectionTail = currentGarbageCollection.catch(() => undefined);
try {
await currentGarbageCollection;
} finally {
pendingGarbageCollections -= 1;
if (pendingGarbageCollections === 0) {
persistedDuringGarbageCollection.clear();
const release = releasePersistenceBarrier;
releasePersistenceBarrier = null;
persistenceBarrier = null;
release?.();
}
}
}