mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +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,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { readTranscriptRequest } from "./read-transcript-request";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
import { ApiError } from "../../api/client";
|
||||
@@ -123,6 +124,12 @@ export function useLiveRunTranscripts({
|
||||
const normalizedRuns = useMemo(() => runs.map((run) => ({ ...run })), [runsKey]);
|
||||
const [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
|
||||
const [hydratedRunIds, setHydratedRunIds] = useState<Set<string>>(new Set());
|
||||
const [errorsByRun, setErrorsByRun] = useState<ReadonlyMap<string, Error>>(new Map());
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
const retry = useCallback(() => {
|
||||
missingTerminalLogRunIdsRef.current.clear();
|
||||
setRetryGeneration((value) => value + 1);
|
||||
}, []);
|
||||
const seenChunkKeysRef = useRef(new Set<string>());
|
||||
// Highest sequenced chunk trimmed out of a run's retained window; older
|
||||
// records re-delivered by the other transport are dropped instead of being
|
||||
@@ -242,6 +249,11 @@ export function useLiveRunTranscripts({
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
|
||||
setErrorsByRun((previous) => {
|
||||
const next = new Map([...previous].filter(([id]) => retainedRunIds.has(id)));
|
||||
return next.size === previous.size ? previous : next;
|
||||
});
|
||||
|
||||
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
||||
const runId = key.replace(/:records$/, "");
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
@@ -285,16 +297,28 @@ export function useLiveRunTranscripts({
|
||||
if (readableRuns.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const inFlightRunIds = new Set<string>();
|
||||
|
||||
const readRunLog = async (run: RunTranscriptSource) => {
|
||||
if (missingTerminalLogRunIdsRef.current.has(run.id)) {
|
||||
if (missingTerminalLogRunIdsRef.current.has(run.id) || inFlightRunIds.has(run.id)) {
|
||||
return;
|
||||
}
|
||||
inFlightRunIds.add(run.id);
|
||||
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
|
||||
try {
|
||||
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
|
||||
const result = await readTranscriptRequest(
|
||||
(signal) => heartbeatsApi.log(run.id, offset, logReadLimitBytes, { signal }),
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
setErrorsByRun((previous) => {
|
||||
if (!previous.has(run.id)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(run.id);
|
||||
return next;
|
||||
});
|
||||
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
||||
|
||||
if (result.nextOffset !== undefined) {
|
||||
@@ -305,10 +329,26 @@ export function useLiveRunTranscripts({
|
||||
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404 && isTerminalStatus(run.status)) {
|
||||
missingTerminalLogRunIdsRef.current.add(run.id);
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setErrorsByRun((previous) => {
|
||||
if (!previous.has(run.id)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.delete(run.id);
|
||||
return next;
|
||||
});
|
||||
// A newly started run may not have created its log yet.
|
||||
if (isTerminalStatus(run.status)) missingTerminalLogRunIdsRef.current.add(run.id);
|
||||
} else {
|
||||
setErrorsByRun((previous) => {
|
||||
if (previous.has(run.id)) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(run.id, error instanceof Error ? error : new Error("Run history could not be loaded"));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
inFlightRunIds.delete(run.id);
|
||||
if (!cancelled) {
|
||||
setHydratedRunIds((prev) => {
|
||||
if (prev.has(run.id)) return prev;
|
||||
@@ -340,9 +380,10 @@ export function useLiveRunTranscripts({
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (interval !== null) window.clearInterval(interval);
|
||||
};
|
||||
}, [enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
|
||||
}, [enableRealtimeUpdates, logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey, retryGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableRealtimeUpdates) return;
|
||||
@@ -519,6 +560,7 @@ export function useLiveRunTranscripts({
|
||||
|
||||
return {
|
||||
transcriptByRun,
|
||||
hydratedRunIds, errorsByRun, retry,
|
||||
isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)),
|
||||
hasOutputForRun(runId: string) {
|
||||
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
|
||||
|
||||
Reference in new issue
Block a user