mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +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
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { nativeRunEventsToTranscript } from "./native-run-events";
|
||||
import { readTranscriptRequest } from "./read-transcript-request";
|
||||
|
||||
const EVENT_PAGE_SIZE = 1_000;
|
||||
const EVENT_POLL_INTERVAL_MS = 2_000;
|
||||
@@ -36,90 +37,116 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
|
||||
);
|
||||
const [eventsByRun, setEventsByRun] = useState<Map<string, HeartbeatRunEvent[]>>(new Map());
|
||||
const [errorsByRun, setErrorsByRun] = useState<Map<string, NativeRunTranscriptError>>(new Map());
|
||||
const [hydratedRunIds, setHydratedRunIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
const retry = useCallback(() => setRetryGeneration((value) => value + 1), []);
|
||||
const projectionCacheRef = useRef(new Map<string, { events: HeartbeatRunEvent[]; transcript: TranscriptEntry[] }>());
|
||||
const cursorByRunRef = useRef(new Map<string, number>());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
const controller = new AbortController();
|
||||
const timers = new Set<number>();
|
||||
const retainedIds = new Set(nativeRuns.map((run) => run.id));
|
||||
const retainMap = <T,>(previous: Map<string, T>) => {
|
||||
const next = new Map([...previous].filter(([id]) => retainedIds.has(id)));
|
||||
return next.size === previous.size ? previous : next;
|
||||
};
|
||||
setEventsByRun(retainMap);
|
||||
setErrorsByRun(retainMap);
|
||||
setHydratedRunIds((previous) => {
|
||||
const next = new Set([...previous].filter((id) => retainedIds.has(id)));
|
||||
return next.size === previous.size ? previous : next;
|
||||
});
|
||||
for (const id of cursorByRunRef.current.keys()) {
|
||||
if (!retainedIds.has(id)) cursorByRunRef.current.delete(id);
|
||||
}
|
||||
|
||||
const refresh = async (runsToRefresh: readonly NativeRunTranscriptSource[]) => {
|
||||
const updates = new Map<string, HeartbeatRunEvent[]>();
|
||||
const errors = new Map<string, NativeRunTranscriptError>();
|
||||
await Promise.all(runsToRefresh.map(async (run) => {
|
||||
try {
|
||||
let cursor = cursorByRunRef.current.get(run.id) ?? 0;
|
||||
const incoming: HeartbeatRunEvent[] = [];
|
||||
for (;;) {
|
||||
const page = await heartbeatsApi.events(run.id, cursor, EVENT_PAGE_SIZE);
|
||||
if (cancelled) return;
|
||||
const last = page.at(-1);
|
||||
const nextCursor = last ? Math.max(cursor, last.seq) : cursor;
|
||||
incoming.push(...page);
|
||||
if (page.length < EVENT_PAGE_SIZE || nextCursor === cursor) {
|
||||
cursor = nextCursor;
|
||||
break;
|
||||
}
|
||||
const refreshRun = async (run: NativeRunTranscriptSource) => {
|
||||
let failed = false;
|
||||
try {
|
||||
let cursor = cursorByRunRef.current.get(run.id) ?? 0;
|
||||
const incoming: HeartbeatRunEvent[] = [];
|
||||
for (;;) {
|
||||
const page = await readTranscriptRequest(
|
||||
(signal) => heartbeatsApi.events(run.id, cursor, EVENT_PAGE_SIZE, { signal }),
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const last = page.at(-1);
|
||||
const nextCursor = last ? Math.max(cursor, last.seq) : cursor;
|
||||
incoming.push(...page.filter((event) => event.seq > cursor));
|
||||
if (page.length < EVENT_PAGE_SIZE || nextCursor === cursor) {
|
||||
cursor = nextCursor;
|
||||
break;
|
||||
}
|
||||
if (incoming.length > 0) updates.set(run.id, incoming);
|
||||
cursorByRunRef.current.set(run.id, cursor);
|
||||
} catch (error) {
|
||||
// Keep the last durable cursor; the next poll retries this run only.
|
||||
errors.set(run.id, {
|
||||
cursor = nextCursor;
|
||||
}
|
||||
// Commit this run's cursor with its rows. A slow sibling must neither
|
||||
// hold its readiness hostage nor stall live polling for this run.
|
||||
cursorByRunRef.current.set(run.id, cursor);
|
||||
if (incoming.length > 0) setEventsByRun((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(run.id, [...(previous.get(run.id) ?? []), ...incoming]);
|
||||
return next;
|
||||
});
|
||||
setErrorsByRun((previous) => {
|
||||
if (!previous.has(run.id)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(run.id);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
failed = true;
|
||||
setErrorsByRun((previous) => {
|
||||
if (previous.has(run.id)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(run.id, {
|
||||
message: error instanceof Error ? error.message : "Native run activity could not be loaded",
|
||||
failedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
const retainedIds = new Set(nativeRuns.map((run) => run.id));
|
||||
for (const runId of cursorByRunRef.current.keys()) {
|
||||
if (!retainedIds.has(runId)) cursorByRunRef.current.delete(runId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setEventsByRun((previous) => {
|
||||
const next = new Map<string, HeartbeatRunEvent[]>();
|
||||
for (const runId of retainedIds) {
|
||||
const current = previous.get(runId) ?? [];
|
||||
const incoming = updates.get(runId) ?? [];
|
||||
next.set(runId, incoming.length > 0 ? [...current, ...incoming] : current);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setErrorsByRun((previous) => {
|
||||
const next = new Map<string, NativeRunTranscriptError>();
|
||||
for (const runId of retainedIds) {
|
||||
const error = errors.get(runId);
|
||||
if (error) next.set(runId, previous.get(runId) ?? error);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nativeRuns.some((run) => isLive(run.status)) || errors.size > 0) {
|
||||
const retryRuns = nativeRuns.filter(
|
||||
(run) => isLive(run.status) || errors.has(run.id),
|
||||
);
|
||||
timer = window.setTimeout(
|
||||
() => void refresh(retryRuns),
|
||||
EVENT_POLL_INTERVAL_MS,
|
||||
);
|
||||
if (cancelled) return;
|
||||
setHydratedRunIds((previous) => previous.has(run.id) ? previous : new Set([...previous, run.id]));
|
||||
if (isLive(run.status) || failed) {
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
void refreshRun(run);
|
||||
}, EVENT_POLL_INTERVAL_MS);
|
||||
timers.add(timer);
|
||||
}
|
||||
};
|
||||
|
||||
void refresh(nativeRuns);
|
||||
for (const run of nativeRuns) void refreshRun(run);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
for (const timer of timers) window.clearTimeout(timer);
|
||||
};
|
||||
}, [nativeRuns]);
|
||||
}, [nativeRuns, retryGeneration]);
|
||||
|
||||
const transcriptByRun = useMemo(() => {
|
||||
const transcripts = new Map<string, TranscriptEntry[]>();
|
||||
for (const run of nativeRuns) {
|
||||
transcripts.set(run.id, nativeRunEventsToTranscript(eventsByRun.get(run.id) ?? []));
|
||||
const events = eventsByRun.get(run.id);
|
||||
if (!events) continue;
|
||||
let cached = projectionCacheRef.current.get(run.id);
|
||||
if (!cached || cached.events !== events) {
|
||||
cached = { events, transcript: nativeRunEventsToTranscript(events) };
|
||||
projectionCacheRef.current.set(run.id, cached);
|
||||
}
|
||||
transcripts.set(run.id, cached.transcript);
|
||||
}
|
||||
for (const id of projectionCacheRef.current.keys()) {
|
||||
if (!transcripts.has(id)) projectionCacheRef.current.delete(id);
|
||||
}
|
||||
return transcripts;
|
||||
}, [eventsByRun, nativeRuns]);
|
||||
|
||||
return { transcriptByRun, errorsByRun };
|
||||
return {
|
||||
transcriptByRun, errorsByRun, hydratedRunIds, retry,
|
||||
isInitialHydrating: nativeRuns.some((run) => !hydratedRunIds.has(run.id)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user