mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(ui): stabilize task loading and live feeds (#13095)
Coordinate initial conversation reveal, preserve message identity and reading anchors during live updates, and bound transcript reads with recoverable retries. Cover desktop/mobile navigation and rich task loading with actual-route browser tests. Verified all Linux CI gates, 5,575 local UI tests, eight layout browser scenarios, and recorded native Codex walkthroughs. Greptile: 5/5; all review findings resolved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
35fdc0c66b
commit
cd4c4ed205
32 files changed
+1286
-179
No files matched your search
@@ -4,6 +4,7 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useNativeRunTranscripts } from "./useNativeRunTranscripts";
|
||||
import { TRANSCRIPT_REQUEST_TIMEOUT_MS } from "./read-transcript-request";
|
||||
|
||||
const eventsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -86,3 +87,83 @@ describe("useNativeRunTranscripts", () => {
|
||||
expect(eventsMock.mock.calls.at(-1)?.[0]).toBe("failed-run");
|
||||
});
|
||||
});
|
||||
|
||||
// Initial readiness is separate from an empty transcript: the task shell must
|
||||
// not reveal empty history while the first durable page is still in flight.
|
||||
describe("native history readiness and stable projection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let latest: ReturnType<typeof useNativeRunTranscripts>;
|
||||
function StateProbe({ runs = [{ id: "one", status: "running", runtimeMode: "native" as const }] }) {
|
||||
latest = useNativeRunTranscripts(runs);
|
||||
return null;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
eventsMock.mockReset();
|
||||
container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("distinguishes pending, loaded-empty and failed, and supports explicit retry", async () => {
|
||||
let resolve!: (rows: never[]) => void;
|
||||
eventsMock.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
|
||||
await act(async () => { root.render(<StateProbe />); });
|
||||
expect(latest.isInitialHydrating).toBe(true);
|
||||
await act(async () => { resolve([]); });
|
||||
expect(latest.isInitialHydrating).toBe(false);
|
||||
expect(latest.hydratedRunIds.has("one")).toBe(true);
|
||||
eventsMock.mockRejectedValueOnce(new Error("offline"));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
|
||||
expect(latest.errorsByRun.has("one")).toBe(true);
|
||||
expect(latest.isInitialHydrating).toBe(false);
|
||||
eventsMock.mockResolvedValue([]);
|
||||
await act(async () => { latest.retry(); });
|
||||
expect(latest.errorsByRun.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not rebuild unchanged history on empty polls", async () => {
|
||||
eventsMock.mockResolvedValueOnce([{ seq: 1, payload: {}, eventType: "log" }]).mockResolvedValue([]);
|
||||
await act(async () => { root.render(<StateProbe />); });
|
||||
const projection = latest.transcriptByRun;
|
||||
const rows = projection.get("one");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
|
||||
expect(latest.transcriptByRun).toBe(projection);
|
||||
expect(latest.transcriptByRun.get("one")).toBe(rows);
|
||||
});
|
||||
|
||||
it("times out stalled history, aborts its request, and ignores a late response before retry", async () => {
|
||||
let resolveLate!: (rows: never[]) => void;
|
||||
eventsMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLate = resolve; }));
|
||||
await act(async () => { root.render(<StateProbe />); });
|
||||
const signal = eventsMock.mock.calls[0][3].signal as AbortSignal;
|
||||
expect(latest.isInitialHydrating).toBe(true);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(TRANSCRIPT_REQUEST_TIMEOUT_MS); });
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(latest.isInitialHydrating).toBe(false);
|
||||
expect(latest.errorsByRun.get("one")?.message).toContain("too long");
|
||||
await act(async () => { resolveLate([]); });
|
||||
expect(latest.errorsByRun.has("one")).toBe(true);
|
||||
eventsMock.mockResolvedValue([]);
|
||||
await act(async () => { latest.retry(); });
|
||||
expect(latest.errorsByRun.size).toBe(0);
|
||||
expect(eventsMock.mock.calls[1][3].signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("commits a resolved run independently of a stalled sibling and retains its cursor", async () => {
|
||||
const rows = [{ seq: 7, payload: {}, eventType: "log" }];
|
||||
eventsMock.mockImplementation((id) => id === "slow" ? new Promise(() => {}) : Promise.resolve(rows));
|
||||
const one = { id: "one", status: "succeeded", runtimeMode: "native" as const };
|
||||
await act(async () => { root.render(<StateProbe runs={[one, { ...one, id: "slow" }]} />); });
|
||||
expect(latest.hydratedRunIds.has("one")).toBe(true);
|
||||
expect(latest.hydratedRunIds.has("slow")).toBe(false);
|
||||
expect(latest.transcriptByRun.has("one")).toBe(true);
|
||||
await act(async () => { root.render(<StateProbe runs={[one]} />); });
|
||||
expect(eventsMock.mock.calls.filter(([id]) => id === "one").map(([, cursor]) => cursor)).toEqual([0, 7]);
|
||||
expect(latest.isInitialHydrating).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user