fix(telegram): keep the streaming preview on screen >=4s before deleting

The streaming "gerund" progress box was deleteMessage'd immediately at
teardown, so on fast turns it flashed and vanished before it could be read,
and the delete could race a just-persisted message (intermittently dropping
the first verbose commentary). Add MIN_PREVIEW_DWELL_MS (4000ms) and schedule
the delete DETACHED via setTimeout for max(0, 4000 - timeVisible), measured
from when the box first appeared. The delete never awaits, so turn teardown
is never stalled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Lindsey
2026-07-02 15:23:35 +08:00
committed by Ayaan Zaidi
parent f5f3a2a571
commit b95fe7f00f
2 changed files with 88 additions and 25 deletions

View File

@@ -313,18 +313,27 @@ describe("createTelegramDraftStream", () => {
});
it("deletes message preview on clear after finalization", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
vi.useFakeTimers();
try {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.stop();
await stream.clear();
stream.update("Hello");
await stream.flush();
stream.update("Hello again");
await stream.stop();
await stream.clear();
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
expectPreviewEdit(api, "Hello again");
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
expectPreviewEdit(api, "Hello again");
// The delete is deferred until the preview has been on screen for the
// dwell window; advance past it to trigger the detached cleanup.
expect(api.deleteMessage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(4_000);
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
} finally {
vi.useRealTimers();
}
});
it("creates new message after forceNewMessage is called", async () => {
@@ -351,20 +360,50 @@ describe("createTelegramDraftStream", () => {
});
it("creates new message after cleanup and forceNewMessage", async () => {
const { api, stream } = createForceNewMessageHarness();
vi.useFakeTimers();
try {
const { api, stream } = createForceNewMessageHarness();
stream.update("Stale preview");
await stream.flush();
stream.update("Stale preview");
await stream.flush();
await stream.clear();
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
await stream.clear();
// Delete is deferred past the dwell window; advance to trigger it.
await vi.advanceTimersByTimeAsync(4_000);
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
stream.forceNewMessage();
stream.update("Next preview");
await stream.flush();
stream.forceNewMessage();
stream.update("Next preview");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 2, "Next preview");
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 2, "Next preview");
} finally {
vi.useRealTimers();
}
});
it("keeps the streaming preview on screen for the dwell window before deleting", async () => {
vi.useFakeTimers();
try {
const api = createMockDraftApi();
const stream = createDraftStream(api);
stream.update("Working");
await stream.flush();
// Fast turn: the preview has only been visible ~1s when the turn tears down.
await vi.advanceTimersByTimeAsync(1_000);
await stream.clear();
// Delete is deferred, not synchronous, and does not fire before the 4s dwell.
await vi.advanceTimersByTimeAsync(2_000);
expect(api.deleteMessage).not.toHaveBeenCalled();
// At the dwell boundary (~4s after first appearing) the detached delete runs.
await vi.advanceTimersByTimeAsync(1_000);
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
} finally {
vi.useRealTimers();
}
});
it("sends first update immediately after forceNewMessage within throttle window", async () => {

View File

@@ -37,6 +37,13 @@ const MAX_CONSECUTIVE_PREVIEW_FAILURES = 3;
// Flood waits beyond this freeze the preview longer than it is useful; clamp so
// a large retry_after cannot park the suspension past the run's lifetime.
const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000;
// Minimum time the streaming preview ("gerund" box) stays on screen before it
// is deleted at teardown, measured from when it first became visible. On fast
// turns the box otherwise flashed and vanished before it could be read, and the
// immediate delete could race a just-persisted message (intermittently dropping
// the first verbose commentary). The delete is scheduled DETACHED so the turn is
// never stalled waiting on the dwell.
const MIN_PREVIEW_DWELL_MS = 4_000;
export type TelegramDraftStream = {
update: (text: string) => void;
@@ -533,6 +540,8 @@ export function createTelegramDraftStream(params: {
};
const clear = async () => {
// Capture before the stop; takeMessageIdAfterStop resets streamVisibleSinceMs.
const visibleSince = streamVisibleSinceMs;
const messageId = await takeMessageIdAfterStop({
stopForClear,
readMessageId: () => streamMessageId,
@@ -541,11 +550,26 @@ export function createTelegramDraftStream(params: {
},
});
if (typeof messageId === "number" && Number.isFinite(messageId)) {
try {
await params.api.deleteMessage(chatId, messageId);
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
} catch (err) {
params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`);
const runDelete = async () => {
try {
await params.api.deleteMessage(chatId, messageId);
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
} catch (err) {
params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`);
}
};
// Keep the preview on screen for at least MIN_PREVIEW_DWELL_MS from when it
// first appeared, then delete DETACHED (scheduled, not awaited) so teardown
// is never stalled waiting for the dwell.
const elapsedMs =
typeof visibleSince === "number" ? Date.now() - visibleSince : MIN_PREVIEW_DWELL_MS;
const remainingMs = Math.max(0, MIN_PREVIEW_DWELL_MS - elapsedMs);
if (remainingMs <= 0) {
void runDelete();
} else {
setTimeout(() => {
void runDelete();
}, remainingMs);
}
}
};