fix(desktop): scope thread typing to the thread root, not reply parent

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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-06 23:23:07 -07:00
co-authored by Taylor Ho
parent 1f5ba5bb27
commit e20408e053
2 changed files with 51 additions and 2 deletions
@@ -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,
);
});
});
@@ -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<RelayEvent, "tags">) {
const reference = getThreadReference(event.tags);
return reference.rootId ?? reference.parentId ?? null;
}
function getTypingStateKey(pubkey: string, threadHeadId: string | null) {