Refine issue workflow surfaces and live updates

This commit is contained in:
dotta
2026-04-09 10:26:17 -05:00
parent b4a58ba8a6
commit 03dff1a29a
48 changed files with 2800 additions and 1163 deletions
@@ -115,4 +115,77 @@ describe("useLiveRunTranscripts", () => {
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
container.remove();
});
it("treats stored run output as available before transcript chunks finish loading", async () => {
let latestHasOutput = false;
function Harness() {
const { hasOutputForRun } = useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", hasStoredOutput: true }],
});
latestHasOutput = hasOutputForRun("run-1");
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(latestHasOutput).toBe(true);
act(() => {
root.unmount();
});
container.remove();
});
it("reports initial hydration until the first persisted-log read completes", async () => {
let latestIsInitialHydrating = false;
type RunLogResult = { runId: string; store: string; logRef: string; content: string; nextOffset: number };
let resolveLog: ((value: RunLogResult | PromiseLike<RunLogResult>) => void) | null = null;
logMock.mockImplementationOnce(
() =>
new Promise<RunLogResult>((resolve) => {
resolveLog = resolve;
}),
);
function Harness() {
const { isInitialHydrating } = useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local" }],
});
latestIsInitialHydrating = isInitialHydrating;
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(latestIsInitialHydrating).toBe(true);
await act(async () => {
resolveLog?.({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 });
await Promise.resolve();
});
expect(latestIsInitialHydrating).toBe(false);
act(() => {
root.unmount();
});
container.remove();
});
});
@@ -13,6 +13,7 @@ export interface RunTranscriptSource {
id: string;
status: string;
adapterType: string;
hasStoredOutput?: boolean;
}
interface UseLiveRunTranscriptsOptions {
@@ -70,7 +71,17 @@ export function useLiveRunTranscripts({
companyId,
maxChunksPerRun = 200,
}: UseLiveRunTranscriptsOptions) {
const runsKey = useMemo(
() =>
runs
.map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`)
.sort((a, b) => a.localeCompare(b))
.join(","),
[runs],
);
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 seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
@@ -84,14 +95,14 @@ export function useLiveRunTranscripts({
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]);
const activeRunIds = useMemo(
() => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
[runs],
() => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
[normalizedRuns],
);
const runIdsKey = useMemo(
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[runs],
() => normalizedRuns.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[normalizedRuns],
);
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
@@ -118,7 +129,7 @@ export function useLiveRunTranscripts({
};
useEffect(() => {
const knownRunIds = new Set(runs.map((run) => run.id));
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
setChunksByRun((prev) => {
const next = new Map<string, RunLogChunk[]>();
for (const [runId, chunks] of prev) {
@@ -128,6 +139,15 @@ export function useLiveRunTranscripts({
}
return next.size === prev.size ? prev : next;
});
setHydratedRunIds((prev) => {
const next = new Set<string>();
for (const runId of prev) {
if (knownRunIds.has(runId)) {
next.add(runId);
}
}
return next.size === prev.size ? prev : next;
});
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
@@ -140,10 +160,10 @@ export function useLiveRunTranscripts({
logOffsetByRunRef.current.delete(runId);
}
}
}, [runs]);
}, [normalizedRuns]);
useEffect(() => {
if (runs.length === 0) return;
if (normalizedRuns.length === 0) return;
let cancelled = false;
@@ -164,15 +184,24 @@ export function useLiveRunTranscripts({
}
} catch {
// Ignore log read errors while output is initializing.
} finally {
if (!cancelled) {
setHydratedRunIds((prev) => {
if (prev.has(run.id)) return prev;
const next = new Set(prev);
next.add(run.id);
return next;
});
}
}
};
const readAll = async () => {
await Promise.all(runs.map((run) => readRunLog(run)));
await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
};
void readAll();
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status));
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0
? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run)));
@@ -183,7 +212,7 @@ export function useLiveRunTranscripts({
cancelled = true;
if (interval !== null) window.clearInterval(interval);
};
}, [runIdsKey, runs]);
}, [normalizedRuns, runIdsKey]);
useEffect(() => {
if (!companyId || activeRunIds.size === 0) return;
@@ -298,7 +327,7 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) {
for (const run of normalizedRuns) {
const adapter = getUIAdapter(run.adapterType);
next.set(
run.id,
@@ -308,12 +337,13 @@ export function useLiveRunTranscripts({
);
}
return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
return {
transcriptByRun,
isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
hasOutputForRun(runId: string) {
return (chunksByRun.get(runId)?.length ?? 0) > 0;
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
},
};
}