Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/web/src/lib/thread-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
computerPanelAutoBoot,
computerTakeoverBlocked,
isThreadSnapshotEvent,
MIN_PROGRESS_VISIBLE_MS,
mergeThreadSnapshot,
prependThreadMessagePage,
reconcileRefreshedThread,
reduceComputerStatus,
reduceThreadSnapshot,
retainMinVisibleLiveProgress,
userHoldsComputerControl,
} from "./thread-events.js";

Expand Down Expand Up @@ -1136,6 +1138,133 @@ describe("computer event reduction", () => {
});
});

describe("min-visible live progress retention", () => {
it("keeps a just-painted chip when a durable answer would strip it", () => {
const run = threadRun("run-1");
const live = {
...message("progress:run-1", [
{ kind: "steps" as const, steps: [{ label: "Schedule", count: 1 }] },
]),
runId: run.id,
};
const previous: ThreadSnapshot = { ...snapshot([live]), run, activeRuns: [run] };
const firstSeen = new Map<string, number>([[live.id, 1_000]]);
const durable = {
...message("msg-1", [{ kind: "text", text: "Done" }], 5),
runId: run.id,
};
const reduced = reduceThreadSnapshot(
previous,
event({
type: "thread.message.created",
seq: 5,
runId: run.id,
payload: { messageId: durable.id, role: "bot", blocks: durable.blocks },
}),
);

expect(reduced?.messages.map((item) => item.id)).toEqual(["msg-1"]);

const held = retainMinVisibleLiveProgress(previous, reduced, firstSeen, 1_100);
expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1", "msg-1"]);
expect(held.clearAfterMs).toEqual([
{ id: "progress:run-1", delayMs: MIN_PROGRESS_VISIBLE_MS - 100 },
]);
expect(held.snapshot?.run).toEqual(run);
});

it("applies terminal run state immediately while holding only the chip", () => {
const run = threadRun("run-1");
const live = {
...message("progress:run-1", [{ kind: "progress" as const, text: "working…" }]),
runId: run.id,
};
const previous: ThreadSnapshot = { ...snapshot([live]), run, activeRuns: [run] };
const firstSeen = new Map<string, number>([[live.id, 2_000]]);
const terminal = reduceThreadSnapshot(
previous,
event({ type: "run.completed", seq: 9, runId: run.id }),
);

expect(terminal?.run).toBeNull();
expect(terminal?.messages).toEqual([]);

const held = retainMinVisibleLiveProgress(previous, terminal, firstSeen, 2_050);
expect(held.snapshot?.run).toBeNull();
expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1"]);
expect(held.clearAfterMs[0]?.delayMs).toBe(MIN_PROGRESS_VISIBLE_MS - 50);
});

it("does not hold beside waiting_input and does not hold after a thread clear", () => {
const run = threadRun("run-1");
const live = {
...message("progress:run-1", [{ kind: "progress" as const, text: "working…" }]),
runId: run.id,
};
const withHistory: ThreadSnapshot = {
...snapshot([message("m-1", [{ kind: "text", text: "hi" }], 1), live]),
run,
activeRuns: [run],
};
const firstSeen = new Map<string, number>([[live.id, 3_000]]);

const waiting = reduceThreadSnapshot(
{ ...snapshot([live]), run, activeRuns: [run] },
event({ type: "run.waiting_input", seq: 6, runId: run.id }),
);
const waitingHeld = retainMinVisibleLiveProgress(
{ ...snapshot([live]), run, activeRuns: [run] },
waiting,
firstSeen,
3_010,
);
expect(waitingHeld.snapshot?.messages).toEqual([]);
expect(waitingHeld.clearAfterMs).toEqual([]);

firstSeen.set(live.id, 3_000);
const cleared = reduceThreadSnapshot(
withHistory,
event({ type: "thread.cleared", seq: 12, runId: undefined }),
);
const clearedHeld = retainMinVisibleLiveProgress(withHistory, cleared, firstSeen, 3_010);
expect(clearedHeld.snapshot?.messages).toEqual([]);
expect(clearedHeld.clearAfterMs).toEqual([]);
});

it("survives a refresh that would otherwise clobber a held chip", () => {
const run = threadRun("run-1");
const live = {
...message("progress:run-1", [
{ kind: "steps" as const, steps: [{ label: "Tool", count: 1 }] },
]),
runId: run.id,
};
const durable = {
...message("msg-1", [{ kind: "text", text: "Answer" }], 8),
runId: run.id,
};
const previous: ThreadSnapshot = {
...snapshot([live, durable]),
cursor: 10,
run: null,
activeRuns: [],
};
const refresh: ThreadSnapshot = {
...snapshot([durable]),
cursor: 10,
run: null,
activeRuns: [],
};
const firstSeen = new Map<string, number>([[live.id, 4_000]]);
const reconciled = reconcileRefreshedThread(previous, refresh, null);
expect(reconciled.snapshot.messages.map((item) => item.id)).toEqual(["msg-1"]);

const held = retainMinVisibleLiveProgress(previous, reconciled.snapshot, firstSeen, 4_200);
expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1", "msg-1"]);
expect(held.clearAfterMs[0]?.delayMs).toBe(MIN_PROGRESS_VISIBLE_MS - 200);
});
});

function snapshot(messages: ThreadMessage[], olderCursor: number | null = null): ThreadSnapshot {
return {
botId: "bot-1",
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/lib/thread-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,123 @@ function takeLiveMessage(
return { previous, remaining };
}

/** Minimum time a painted progress/steps chip should stay on screen. */
export const MIN_PROGRESS_VISIBLE_MS = 700;
/** Cap first-seen entries so a drop/reconnect cannot grow the map forever. */
export const PROGRESS_FIRST_SEEN_LIMIT = 200;

export function liveProgressIsVisible(message: ThreadMessage): boolean {
return message.blocks.some(
(block) => block.kind === "steps" || (block.kind === "progress" && Boolean(block.text)),
);
}

export function noteProgressFirstSeen(
messages: readonly ThreadMessage[],
firstSeen: Map<string, number>,
now: number,
limit = PROGRESS_FIRST_SEEN_LIMIT,
): void {
for (const message of messages) {
if (!message.id.startsWith("progress:") || !liveProgressIsVisible(message)) continue;
if (firstSeen.has(message.id)) continue;
firstSeen.set(message.id, now);
while (firstSeen.size > limit) {
const oldest = firstSeen.keys().next().value;
if (oldest === undefined) break;
firstSeen.delete(oldest);
}
}
}

/**
* Keep a just-painted live progress/steps chip when an event or refresh would strip it
* before MIN_PROGRESS_VISIBLE_MS. Terminal run state and durable messages still apply;
* only the live-message removal is deferred.
*/
export function retainMinVisibleLiveProgress(
previous: ThreadSnapshot | null,
next: ThreadSnapshot | null,
firstSeen: Map<string, number>,
now = Date.now(),
minVisibleMs = MIN_PROGRESS_VISIBLE_MS,
): { snapshot: ThreadSnapshot | null; clearAfterMs: Array<{ id: string; delayMs: number }> } {
if (!next) return { snapshot: next, clearAfterMs: [] };
noteProgressFirstSeen(next.messages, firstSeen, now);
if (!previous || previous.threadId !== next.threadId) {
return { snapshot: next, clearAfterMs: [] };
}

// thread.cleared wipes durable history; never reattach live chips onto an empty transcript.
const previousHadDurable = previous.messages.some(
(message) => !isTransientThreadMessage(message),
);
const nextHasDurable = next.messages.some((message) => !isTransientThreadMessage(message));
if (previousHadDurable && !nextHasDurable) {
for (const message of previous.messages) {
if (message.id.startsWith("progress:")) firstSeen.delete(message.id);
}
return { snapshot: next, clearAfterMs: [] };
}

const nextIds = new Set(next.messages.map((message) => message.id));
const held: ThreadMessage[] = [];
const clearAfterMs: Array<{ id: string; delayMs: number }> = [];

for (const message of previous.messages) {
if (!message.id.startsWith("progress:") || nextIds.has(message.id)) continue;
if (!liveProgressIsVisible(message)) continue;
const seenAt = firstSeen.get(message.id);
if (seenAt === undefined) continue;

// Ask pauses must not leave "working…" beside waiting_input cards.
const runId = message.runId;
const nextRun = runId
? (next.activeRuns?.find((candidate) => candidate.id === runId) ??
(next.run?.id === runId ? next.run : undefined))
: undefined;
if (nextRun?.status === "waiting_input") {
firstSeen.delete(message.id);
continue;
}

const remaining = minVisibleMs - (now - seenAt);
if (remaining <= 0) {
firstSeen.delete(message.id);
continue;
}
held.push(message);
clearAfterMs.push({ id: message.id, delayMs: remaining });
}

if (held.length === 0) return { snapshot: next, clearAfterMs: [] };
return {
snapshot: { ...next, messages: insertHeldLiveProgress(next.messages, held) },
clearAfterMs,
};
}

function isTransientThreadMessage(message: ThreadMessage): boolean {
return message.id.startsWith("progress:") || message.id.startsWith("subagent:");
}

function insertHeldLiveProgress(
messages: readonly ThreadMessage[],
held: readonly ThreadMessage[],
): ThreadMessage[] {
const next = [...messages];
for (const live of held) {
if (next.some((message) => message.id === live.id)) continue;
const durableIndex = next.findIndex(
(message) =>
Boolean(live.runId) && message.runId === live.runId && !message.id.startsWith("progress:"),
);
if (durableIndex >= 0) next.splice(durableIndex, 0, live);
else next.push(live);
}
return next;
}

const computerStates: ReadonlySet<unknown> = new Set<ComputerStatus["state"]>([
"stopped",
"booting",
Expand Down
69 changes: 64 additions & 5 deletions apps/web/src/pages/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
reconcileRefreshedThread,
reduceComputerStatus,
reduceThreadSnapshot,
retainMinVisibleLiveProgress,
userHoldsComputerControl,
} from "../lib/thread-events";
import { speaker } from "../lib/tts";
Expand Down Expand Up @@ -207,10 +208,57 @@ export function ShellPage() {
const computerRef = useRef<ComputerStatus | null>(null);
const threadRefreshEpoch = useRef(0);
const groupRefreshEpoch = useRef(0);
// First-seen timestamp per progress/steps message id. Commit retains a
// just-painted chip for MIN_PROGRESS_VISIBLE_MS when events/refreshes would
// strip it — without stalling the SSE subscribe loop. Capped so a message
// whose clear never reaches this client doesn't linger forever.
const progressFirstSeenRef = useRef(new Map<string, number>());
const progressClearTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());

function clearProgressHold(id: string) {
progressFirstSeenRef.current.delete(id);
const timer = progressClearTimersRef.current.get(id);
if (timer !== undefined) {
clearTimeout(timer);
progressClearTimersRef.current.delete(id);
}
}

function dropLiveProgressHolds(messages: readonly ThreadMessage[]) {
for (const message of messages) {
if (message.id.startsWith("progress:")) clearProgressHold(message.id);
}
}

function dropAllProgressHolds() {
for (const timer of progressClearTimersRef.current.values()) clearTimeout(timer);
progressClearTimersRef.current.clear();
progressFirstSeenRef.current.clear();
}

function commitSnapshot(next: ThreadSnapshot | null) {
snapshotRef.current = next;
setSnapshot(next);
const { snapshot, clearAfterMs } = retainMinVisibleLiveProgress(
snapshotRef.current,
next,
progressFirstSeenRef.current,
);
snapshotRef.current = snapshot;
setSnapshot(snapshot);
for (const { id, delayMs } of clearAfterMs) {
const existing = progressClearTimersRef.current.get(id);
if (existing !== undefined) clearTimeout(existing);
const timer = setTimeout(() => {
progressClearTimersRef.current.delete(id);
progressFirstSeenRef.current.delete(id);
const current = snapshotRef.current;
if (!current?.messages.some((message) => message.id === id)) return;
commitSnapshot({
...current,
messages: current.messages.filter((message) => message.id !== id),
});
}, delayMs);
progressClearTimersRef.current.set(id, timer);
}
}

function commitComputer(next: ComputerStatus | null) {
Expand Down Expand Up @@ -696,6 +744,12 @@ export function ShellPage() {
};
}, [active?.id, markBotReadIfVisible]);

useEffect(() => {
return () => {
dropAllProgressHolds();
};
}, []);

useEffect(() => {
if (!active) return;
if (!searchParams.get("m")) {
Expand All @@ -705,6 +759,7 @@ export function ShellPage() {
setScreenUrl(null);
expandedHistoryThread.current = null;
historyEpoch.current += 1;
dropAllProgressHolds();
const abort = new AbortController();
void (async () => {
const primed = bootstrappedThread.current;
Expand Down Expand Up @@ -776,6 +831,7 @@ export function ShellPage() {
if (!groupId || !activeGroup) return;
manuallyUnread.current.delete(activeGroup.id);
readVisibleGroups.current.delete(groupId);
dropAllProgressHolds();
const markVisibleGroupRead = () => {
if (
document.visibilityState !== "visible" ||
Expand Down Expand Up @@ -1167,9 +1223,11 @@ export function ShellPage() {
}
// Stop has no terminal event; clear run UI before refresh races with in-flight gets.
if (activeGroupId.current === groupTarget) {
updateSnapshot((prev) =>
prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev,
);
updateSnapshot((prev) => {
if (!prev || prev.groupId !== groupTarget) return prev;
dropLiveProgressHolds(prev.messages);
return clearActiveThreadRuns(prev);
});
}
await refreshGroupThreadRef.current(groupTarget).catch(() => undefined);
return;
Expand All @@ -1190,6 +1248,7 @@ export function ShellPage() {
if (activeBotId.current === botTarget) {
updateSnapshot((prev) => {
if (!prev || (prev.botId !== botTarget && prev.botId)) return prev;
dropLiveProgressHolds(prev.messages);
return clearActiveThreadRuns(prev);
});
const currentComputer = computerRef.current;
Expand Down