forked from farhoodlabs/paperclip
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
@@ -110,4 +110,23 @@ describe("RunTranscriptView", () => {
|
||||
expect(html).toMatch(/<li[^>]*>posted issue update<\/li>/);
|
||||
expect(html).not.toContain("result");
|
||||
});
|
||||
|
||||
it("windows large raw transcripts instead of rendering every entry at once", () => {
|
||||
const entries: TranscriptEntry[] = Array.from({ length: 500 }, (_, index) => ({
|
||||
kind: "stdout",
|
||||
ts: `2026-03-12T00:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
text: `line-${index}`,
|
||||
}));
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunTranscriptView mode="raw" entries={entries} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("line-0");
|
||||
expect(html).toContain("line-179");
|
||||
expect(html).not.toContain("line-250");
|
||||
expect(html).not.toContain("line-499");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { MarkdownBody } from "../MarkdownBody";
|
||||
import { cn, formatTokens } from "../../lib/utils";
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
export type TranscriptMode = "nice" | "raw";
|
||||
export type TranscriptDensity = "comfortable" | "compact";
|
||||
|
||||
const RAW_VIRTUALIZATION_THRESHOLD = 300;
|
||||
const RAW_OVERSCAN_ROWS = 40;
|
||||
const RAW_ESTIMATED_ROW_HEIGHT = 36;
|
||||
const RAW_INITIAL_ROWS = 180;
|
||||
|
||||
interface RunTranscriptViewProps {
|
||||
entries: TranscriptEntry[];
|
||||
mode?: TranscriptMode;
|
||||
@@ -1347,6 +1352,34 @@ function TranscriptStdoutRow({
|
||||
);
|
||||
}
|
||||
|
||||
function findScrollParent(element: HTMLElement): HTMLElement | Window {
|
||||
let current = element.parentElement;
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (/(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function rawEntryContent(entry: TranscriptEntry): string {
|
||||
if (entry.kind === "tool_call") {
|
||||
return `${entry.name}\n${formatToolPayload(entry.input)}`;
|
||||
}
|
||||
if (entry.kind === "tool_result") {
|
||||
return formatToolPayload(entry.content);
|
||||
}
|
||||
if (entry.kind === "result") {
|
||||
return `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`;
|
||||
}
|
||||
if (entry.kind === "init") {
|
||||
return `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`;
|
||||
}
|
||||
return entry.text;
|
||||
}
|
||||
|
||||
function RawTranscriptView({
|
||||
entries,
|
||||
density,
|
||||
@@ -1355,11 +1388,63 @@ function RawTranscriptView({
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const compact = density === "compact";
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldVirtualize = entries.length > RAW_VIRTUALIZATION_THRESHOLD;
|
||||
const [range, setRange] = useState(() => ({
|
||||
start: 0,
|
||||
end: Math.min(entries.length, shouldVirtualize ? RAW_INITIAL_ROWS : entries.length),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldVirtualize) {
|
||||
setRange({ start: 0, end: entries.length });
|
||||
return;
|
||||
}
|
||||
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
const scrollParent = findScrollParent(list);
|
||||
const updateRange = () => {
|
||||
const scrollElement: HTMLElement | null = scrollParent === window ? null : (scrollParent as HTMLElement);
|
||||
const scrollerTop = scrollElement ? scrollElement.getBoundingClientRect().top : 0;
|
||||
const scrollerHeight = scrollElement ? scrollElement.clientHeight : window.innerHeight;
|
||||
const listTop = list.getBoundingClientRect().top;
|
||||
const visibleTop = Math.max(0, scrollerTop - listTop);
|
||||
const visibleBottom = Math.max(visibleTop + scrollerHeight, 0);
|
||||
const nextStart = Math.max(0, Math.floor(visibleTop / RAW_ESTIMATED_ROW_HEIGHT) - RAW_OVERSCAN_ROWS);
|
||||
const nextEnd = Math.min(
|
||||
entries.length,
|
||||
Math.ceil(visibleBottom / RAW_ESTIMATED_ROW_HEIGHT) + RAW_OVERSCAN_ROWS,
|
||||
);
|
||||
setRange((current) => (
|
||||
current.start === nextStart && current.end === nextEnd
|
||||
? current
|
||||
: { start: nextStart, end: nextEnd }
|
||||
));
|
||||
};
|
||||
|
||||
updateRange();
|
||||
const frame = window.requestAnimationFrame(updateRange);
|
||||
scrollParent.addEventListener("scroll", updateRange, { passive: true });
|
||||
window.addEventListener("resize", updateRange);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
scrollParent.removeEventListener("scroll", updateRange);
|
||||
window.removeEventListener("resize", updateRange);
|
||||
};
|
||||
}, [entries.length, shouldVirtualize]);
|
||||
|
||||
const visibleEntries = shouldVirtualize ? entries.slice(range.start, range.end) : entries;
|
||||
const topSpacer = shouldVirtualize ? range.start * RAW_ESTIMATED_ROW_HEIGHT : 0;
|
||||
const bottomSpacer = shouldVirtualize ? Math.max(0, entries.length - range.end) * RAW_ESTIMATED_ROW_HEIGHT : 0;
|
||||
|
||||
return (
|
||||
<div className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}>
|
||||
{entries.map((entry, idx) => (
|
||||
<div ref={listRef} className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}>
|
||||
{topSpacer > 0 && <div aria-hidden="true" style={{ height: topSpacer }} />}
|
||||
{visibleEntries.map((entry, idx) => (
|
||||
<div
|
||||
key={`${entry.kind}-${entry.ts}-${idx}`}
|
||||
key={`${entry.kind}-${entry.ts}-${range.start + idx}`}
|
||||
className={cn(
|
||||
"grid gap-x-3",
|
||||
"grid-cols-[auto_1fr]",
|
||||
@@ -1369,18 +1454,11 @@ function RawTranscriptView({
|
||||
{entry.kind}
|
||||
</span>
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words text-foreground/80">
|
||||
{entry.kind === "tool_call"
|
||||
? `${entry.name}\n${formatToolPayload(entry.input)}`
|
||||
: entry.kind === "tool_result"
|
||||
? formatToolPayload(entry.content)
|
||||
: entry.kind === "result"
|
||||
? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
|
||||
: entry.kind === "init"
|
||||
? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`
|
||||
: entry.text}
|
||||
{rawEntryContent(entry)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{bottomSpacer > 0 && <div aria-hidden="true" style={{ height: bottomSpacer }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1396,7 +1474,10 @@ export function RunTranscriptView({
|
||||
className,
|
||||
thinkingClassName,
|
||||
}: RunTranscriptViewProps) {
|
||||
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
|
||||
const blocks = useMemo(
|
||||
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
|
||||
[entries, mode, streaming],
|
||||
);
|
||||
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
|
||||
const visibleEntries = limit ? entries.slice(-limit) : entries;
|
||||
|
||||
|
||||
@@ -258,6 +258,34 @@ describe("useLiveRunTranscripts", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("starts persisted-log hydration from the newest bytes when the visible window is truncated", async () => {
|
||||
function Harness() {
|
||||
useLiveRunTranscripts({
|
||||
companyId: "company-1",
|
||||
runs: [{ id: "run-1", status: "running", adapterType: "codex_local", lastOutputBytes: 100_000 }],
|
||||
enableRealtimeUpdates: false,
|
||||
logReadLimitBytes: 64_000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(logMock).toHaveBeenCalledWith("run-1", 36_000, 64_000);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("rebuilds only the transcript for the run that receives live output", async () => {
|
||||
function Harness() {
|
||||
useLiveRunTranscripts({
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface RunTranscriptSource {
|
||||
status: string;
|
||||
adapterType: string;
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
lastOutputBytes?: number | null;
|
||||
}
|
||||
|
||||
interface UseLiveRunTranscriptsOptions {
|
||||
@@ -35,6 +37,19 @@ function isTerminalStatus(status: string): boolean {
|
||||
return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded";
|
||||
}
|
||||
|
||||
function runKnownLogBytes(run: RunTranscriptSource): number | null {
|
||||
const bytes = run.status === "queued"
|
||||
? run.logBytes
|
||||
: run.lastOutputBytes ?? run.logBytes;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes > 0 ? bytes : null;
|
||||
}
|
||||
|
||||
export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: number): number {
|
||||
const knownBytes = runKnownLogBytes(run);
|
||||
if (knownBytes === null) return 0;
|
||||
return Math.max(0, knownBytes - Math.max(0, limitBytes));
|
||||
}
|
||||
|
||||
function parsePersistedLogContent(
|
||||
runId: string,
|
||||
content: string,
|
||||
@@ -82,7 +97,11 @@ export function useLiveRunTranscripts({
|
||||
const runsKey = useMemo(
|
||||
() =>
|
||||
runs
|
||||
.map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`)
|
||||
.map((run) => {
|
||||
const logBytes = typeof run.logBytes === "number" ? run.logBytes : "";
|
||||
const lastOutputBytes = typeof run.lastOutputBytes === "number" ? run.lastOutputBytes : "";
|
||||
return `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}:${logBytes}:${lastOutputBytes}`;
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(","),
|
||||
[runs],
|
||||
@@ -197,7 +216,7 @@ export function useLiveRunTranscripts({
|
||||
if (missingTerminalLogRunIdsRef.current.has(run.id)) {
|
||||
return;
|
||||
}
|
||||
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
||||
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
|
||||
try {
|
||||
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
|
||||
if (cancelled) return;
|
||||
|
||||
Reference in New Issue
Block a user