diff --git a/desktop/src/features/messages/useChannelTyping.test.mjs b/desktop/src/features/messages/useChannelTyping.test.mjs index d028d59e37..8bcc1a129d 100644 --- a/desktop/src/features/messages/useChannelTyping.test.mjs +++ b/desktop/src/features/messages/useChannelTyping.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { recordTypingCompletion } from "./useChannelTyping.ts"; +import { + clearTypingStateForCompletion, + recordTypingCompletion, +} from "./useChannelTyping.ts"; const TYPING_KEY = "agent:thread"; const NOW = 2_000_000_000_000; @@ -14,17 +17,12 @@ function makeState() { }; } -function record( - state, - createdAt, - { hasActiveTyping = true, now = NOW, typingCreatedAt = createdAt } = {}, -) { +function record(state, createdAt, { now = NOW } = {}) { return recordTypingCompletion({ createdAt, latestMessageCreatedAtByPubkey: state.latestMessageCreatedAtByPubkey, now, suppressUntilByPubkey: state.suppressUntilByPubkey, - typingCreatedAt: hasActiveTyping ? typingCreatedAt : undefined, typingKey: TYPING_KEY, }); } @@ -33,7 +31,7 @@ test("duplicate full-array replay does not reprocess completion events", () => { const state = makeState(); assert.equal(record(state, NOW_SECONDS), true); - assert.equal(record(state, NOW_SECONDS), false); + assert.equal(record(state, NOW_SECONDS, { now: NOW + 1_000 }), false); assert.deepEqual(state.latestMessageCreatedAtByPubkey, { [TYPING_KEY]: { createdAt: NOW_SECONDS, observedAt: NOW }, }); @@ -66,17 +64,18 @@ test("older-then-newer completion arrival advances the watermark", () => { assert.equal(state.suppressUntilByPubkey[TYPING_KEY], NOW + 2_000); }); -test("historical thread load records ordering but creates no fresh suppression", () => { +test("historical first observation arms one bounded suppression for trailing ticks", () => { const state = makeState(); + const historicalCreatedAt = NOW_SECONDS - 60; - assert.equal( - record(state, NOW_SECONDS - 60, { hasActiveTyping: false }), - false, - ); + assert.equal(record(state, historicalCreatedAt), true); + assert.equal(record(state, historicalCreatedAt, { now: NOW + 1_000 }), false); assert.deepEqual(state.latestMessageCreatedAtByPubkey, { - [TYPING_KEY]: { createdAt: NOW_SECONDS - 60, observedAt: NOW }, + [TYPING_KEY]: { createdAt: historicalCreatedAt, observedAt: NOW }, + }); + assert.deepEqual(state.suppressUntilByPubkey, { + [TYPING_KEY]: NOW + 2_000, }); - assert.deepEqual(state.suppressUntilByPubkey, {}); }); test("lagging agent completion clears typing for a matching agent-clock event", () => { @@ -99,14 +98,43 @@ test("ahead agent completion keeps suppression desktop-local", () => { }); }); -test("message older than active typing advances ordering without clearing", () => { - const state = makeState(); +test("queued newer typing survives an older completion clear", () => { + const typingStateAfterQueuedTick = { + [TYPING_KEY]: { + createdAt: NOW_SECONDS + 1, + expiresAt: NOW + 8_000, + firstSeenAt: NOW, + pubkey: "agent", + threadHeadId: "thread", + }, + }; assert.equal( - record(state, NOW_SECONDS - 60, { typingCreatedAt: NOW_SECONDS }), - false, + clearTypingStateForCompletion( + typingStateAfterQueuedTick, + TYPING_KEY, + NOW_SECONDS, + NOW, + ), + typingStateAfterQueuedTick, + ); +}); + +test("completion clears matching or older typing state", () => { + const typingState = { + [TYPING_KEY]: { + createdAt: NOW_SECONDS, + expiresAt: NOW + 8_000, + firstSeenAt: NOW, + pubkey: "agent", + threadHeadId: "thread", + }, + }; + + assert.deepEqual( + clearTypingStateForCompletion(typingState, TYPING_KEY, NOW_SECONDS, NOW), + {}, ); - assert.deepEqual(state.suppressUntilByPubkey, {}); }); test("watermarks expire by desktop observation time, not agent clock", () => { diff --git a/desktop/src/features/messages/useChannelTyping.ts b/desktop/src/features/messages/useChannelTyping.ts index 9a02ed9afc..fdcbc9e421 100644 --- a/desktop/src/features/messages/useChannelTyping.ts +++ b/desktop/src/features/messages/useChannelTyping.ts @@ -43,22 +43,20 @@ const TYPING_POST_MESSAGE_SUPPRESS_MS = 2_000; * agent's clock domain; retention and suppression use the observing desktop's * clock so host skew cannot disable or stretch typing cleanup. * - * Returns whether matching typing state should be cleared. Exported so replay, - * out-of-order, and clock-skew behavior stays unit-tested. + * Returns whether this completion newly advanced the scope watermark. Exported so + * replay, out-of-order, and clock-skew behavior stays unit-tested. */ export function recordTypingCompletion({ createdAt, latestMessageCreatedAtByPubkey, now = Date.now(), suppressUntilByPubkey, - typingCreatedAt, typingKey, }: { createdAt: number; latestMessageCreatedAtByPubkey: TypingCompletionWatermarks; now?: number; suppressUntilByPubkey: TypingSuppressionDeadlines; - typingCreatedAt?: number; typingKey: string; }) { for (const [key, watermark] of Object.entries( @@ -80,9 +78,8 @@ export function recordTypingCompletion({ return false; } latestMessageCreatedAtByPubkey[typingKey] = { createdAt, observedAt: now }; - if (typingCreatedAt === undefined || createdAt < typingCreatedAt) { - return false; - } + // Arm even without visible typing: the harness can publish a trailing tick + // after its reply, and that tick must not resurrect a completed-turn pill. suppressUntilByPubkey[typingKey] = now + TYPING_POST_MESSAGE_SUPPRESS_MS; return true; } @@ -103,6 +100,23 @@ function pruneTypingState(state: TypingState, now = Date.now()) { return changed ? next : state; } +export function clearTypingStateForCompletion( + state: TypingState, + typingKey: string, + completionCreatedAt: number, + now = Date.now(), +) { + const next = pruneTypingState(state, now); + const typingEntry = next[typingKey]; + if (!typingEntry || completionCreatedAt < typingEntry.createdAt) { + return next; + } + + const updated = { ...next }; + delete updated[typingKey]; + return updated; +} + function isTypingCompletionEvent(event: RelayEvent | null | undefined) { if (!event) { return false; @@ -222,27 +236,19 @@ export function useChannelTyping( }).toLowerCase(); const threadHeadId = getTypingScopeId(event); const typingKey = getTypingStateKey(authorPubkey, threadHeadId); - const shouldClearTyping = recordTypingCompletion({ + const isNewCompletion = recordTypingCompletion({ createdAt: event.created_at, latestMessageCreatedAtByPubkey: latestMessageCreatedAtByPubkeyRef.current, suppressUntilByPubkey: typingSuppressUntilByPubkeyRef.current, - typingCreatedAt: typingByPubkey[typingKey]?.createdAt, typingKey, }); - if (!shouldClearTyping) { + if (!isNewCompletion) { return; } - setTypingByPubkey((current) => { - const next = pruneTypingState(current); - if (!(typingKey in next)) { - return next; - } - - const updated = { ...next }; - delete updated[typingKey]; - return updated; - }); + setTypingByPubkey((current) => + clearTypingStateForCompletion(current, typingKey, event.created_at), + ); }); useEffect(() => {