From e20408e053c6ea84c351ff1a10322278077f1cee Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 21:41:53 -0700 Subject: [PATCH] fix(desktop): scope thread typing to the thread root, not reply parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTypingScopeId keyed typing entries on the reply parent tag, so an agent working deep in a thread produced a threadHeadId that matched no ingress row (and missed the open-thread composer filter). Typing and completion events already carry a root tag for nested replies — prefer it, falling back to the parent for direct replies where root === parent and no root tag is emitted. Both the registration and post-message suppression paths flow through the same helper, so suppression keys stay consistent. Exported for tests. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/useChannelTyping.test.mjs | 42 +++++++++++++++++++ .../src/features/messages/useChannelTyping.ts | 11 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/useChannelTyping.test.mjs diff --git a/desktop/src/features/messages/useChannelTyping.test.mjs b/desktop/src/features/messages/useChannelTyping.test.mjs new file mode 100644 index 000000000..7a1bdf0ad --- /dev/null +++ b/desktop/src/features/messages/useChannelTyping.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getTypingScopeId } from "./useChannelTyping.ts"; + +const ROOT = "aaaa".repeat(16); +const PARENT = "bbbb".repeat(16); +const CHANNEL = "11111111-1111-1111-1111-111111111111"; + +describe("getTypingScopeId", () => { + it("returns null for channel-scoped typing (no e tags)", () => { + assert.equal(getTypingScopeId({ tags: [["h", CHANNEL]] }), null); + }); + + it("uses the reply parent for direct replies to the thread head", () => { + // Direct replies tag only the parent (root === parent, so no root tag). + assert.equal( + getTypingScopeId({ + tags: [ + ["h", CHANNEL], + ["e", ROOT, "", "reply"], + ], + }), + ROOT, + ); + }); + + it("prefers the thread root over the reply parent for nested replies", () => { + // Nested replies tag both root and their immediate parent; thread + // surfaces (ingress badge, open-thread composer) key on the root. + assert.equal( + getTypingScopeId({ + tags: [ + ["h", CHANNEL], + ["e", ROOT, "", "root"], + ["e", PARENT, "", "reply"], + ], + }), + ROOT, + ); + }); +}); diff --git a/desktop/src/features/messages/useChannelTyping.ts b/desktop/src/features/messages/useChannelTyping.ts index 1b551ac0c..acc15c28a 100644 --- a/desktop/src/features/messages/useChannelTyping.ts +++ b/desktop/src/features/messages/useChannelTyping.ts @@ -57,8 +57,15 @@ function isTypingCompletionEvent(event: RelayEvent | null | undefined) { ); } -function getTypingScopeId(event: RelayEvent) { - return getThreadReference(event.tags).parentId ?? null; +/** + * Thread scope for a typing/completion event: the thread root, not the + * immediate reply parent. Agents replying deep in a thread tag their nested + * parent, but every thread surface (ingress badge, open-thread composer) + * keys on the thread head id. Exported for tests. + */ +export function getTypingScopeId(event: Pick) { + const reference = getThreadReference(event.tags); + return reference.rootId ?? reference.parentId ?? null; } function getTypingStateKey(pubkey: string, threadHeadId: string | null) {