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 1/2] 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 0000000000..7a1bdf0ad8 --- /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 1b551ac0c1..acc15c28a3 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) { From 42e81166d52965482d27bbbf541706ffc45d1923 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 7 Jul 2026 00:07:31 -0700 Subject: [PATCH 2/2] feat(desktop): add header-level threads attention menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads needing attention were hard to chase down inside a busy timeline, so the affordance moves to the channel header: a ReceiptText control left of the Users button, badged with the channel's unread-reply total, opening one combined list of unread and active threads. Selecting a row opens the thread panel scroll-focused on the thread head. - threadAttention.ts: pure derivation — merge unread counts with active threads (active first by start recency, then unread by reply recency), coarse Ns/Nm/Nh uptime formatter, single-line head previews. - useThreadAttention.ts: 'active' comes from thread-scoped bot typing; uptime anchors to a session-local first-seen map keyed on a stable active-set key, and rows are field-wise stabilized so busy-channel timeline churn never re-renders the header memo. - ChannelThreadsAttentionMenu.tsx: trigger + badge + combined list; the uptime tick mounts only while the menu is open. - handleOpenThreadFocused: non-toggle open + thread-head scroll target, so re-selecting an already-open thread refocuses instead of closing it. - Wired through ChannelScreenHeader/ChannelMembersBar in both inline and compact variants; DMs get the control (DMs are channels), forums are gated off since they render no thread panel. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/lib/threadAttention.test.mjs | 196 ++++++++++++++++++ .../features/channels/lib/threadAttention.ts | 126 +++++++++++ .../channels/ui/ChannelMembersBar.tsx | 87 +++++--- .../features/channels/ui/ChannelScreen.tsx | 23 ++ .../channels/ui/ChannelScreenHeader.tsx | 10 + .../ui/ChannelThreadsAttentionMenu.tsx | 136 ++++++++++++ .../channels/ui/useThreadAttention.ts | 89 ++++++++ .../channels/useChannelPaneHandlers.ts | 28 +++ 8 files changed, 662 insertions(+), 33 deletions(-) create mode 100644 desktop/src/features/channels/lib/threadAttention.test.mjs create mode 100644 desktop/src/features/channels/lib/threadAttention.ts create mode 100644 desktop/src/features/channels/ui/ChannelThreadsAttentionMenu.tsx create mode 100644 desktop/src/features/channels/ui/useThreadAttention.ts diff --git a/desktop/src/features/channels/lib/threadAttention.test.mjs b/desktop/src/features/channels/lib/threadAttention.test.mjs new file mode 100644 index 0000000000..c7b76df8dd --- /dev/null +++ b/desktop/src/features/channels/lib/threadAttention.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + areThreadAttentionRowsEqual, + buildHeadPreview, + buildThreadAttentionRows, + formatCoarseUptime, + totalUnreadCount, +} from "./threadAttention.ts"; + +describe("formatCoarseUptime", () => { + it("renders whole seconds under a minute", () => { + assert.equal(formatCoarseUptime(0), "0s"); + assert.equal(formatCoarseUptime(999), "0s"); + assert.equal(formatCoarseUptime(45_000), "45s"); + assert.equal(formatCoarseUptime(59_999), "59s"); + }); + + it("renders whole minutes under an hour, no seconds", () => { + assert.equal(formatCoarseUptime(60_000), "1m"); + assert.equal(formatCoarseUptime(3 * 60_000 + 12_000), "3m"); + assert.equal(formatCoarseUptime(59 * 60_000 + 59_000), "59m"); + }); + + it("renders whole hours beyond an hour, no minutes", () => { + assert.equal(formatCoarseUptime(60 * 60_000), "1h"); + assert.equal(formatCoarseUptime(2 * 60 * 60_000 + 31 * 60_000), "2h"); + }); + + it("clamps negative durations to 0s", () => { + assert.equal(formatCoarseUptime(-5_000), "0s"); + }); +}); + +describe("buildHeadPreview", () => { + it("collapses whitespace to one line", () => { + assert.equal(buildHeadPreview("a\nb\t c"), "a b c"); + }); + + it("returns null for blank bodies", () => { + assert.equal(buildHeadPreview(" \n "), null); + }); + + it("caps long bodies with an ellipsis", () => { + const preview = buildHeadPreview("x".repeat(200)); + assert.equal(preview.length, 141); + assert.ok(preview.endsWith("…")); + }); +}); + +function build({ + active = new Map(), + heads = new Map(), + summaries = new Map(), + unread = new Map(), +} = {}) { + return buildThreadAttentionRows({ + activeSinceByThread: active, + getHeadMessage: (id) => heads.get(id), + getThreadSummary: (id) => summaries.get(id), + threadUnreadCounts: unread, + }); +} + +describe("buildThreadAttentionRows", () => { + it("returns empty for no unread and no active threads", () => { + assert.deepEqual(build(), []); + }); + + it("drops threads whose unread count is zero", () => { + const rows = build({ unread: new Map([["t1", 0]]) }); + assert.deepEqual(rows, []); + }); + + it("merges a thread that is both unread and active into one row", () => { + const rows = build({ + active: new Map([["t1", 1_000]]), + unread: new Map([["t1", 3]]), + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].threadHeadId, "t1"); + assert.equal(rows[0].unreadCount, 3); + assert.equal(rows[0].activeSince, 1_000); + }); + + it("sorts active rows first, newest activity on top", () => { + const rows = build({ + active: new Map([ + ["t1", 1_000], + ["t2", 5_000], + ]), + summaries: new Map([["t3", { descendantCount: 2, lastReplyAt: 99 }]]), + unread: new Map([["t3", 1]]), + }); + assert.deepEqual( + rows.map((row) => row.threadHeadId), + ["t2", "t1", "t3"], + ); + }); + + it("sorts unread-only rows by reply recency, newest first", () => { + const rows = build({ + summaries: new Map([ + ["t1", { descendantCount: 1, lastReplyAt: 10 }], + ["t2", { descendantCount: 1, lastReplyAt: 30 }], + ["t3", { descendantCount: 1, lastReplyAt: 20 }], + ]), + unread: new Map([ + ["t1", 1], + ["t2", 1], + ["t3", 1], + ]), + }); + assert.deepEqual( + rows.map((row) => row.threadHeadId), + ["t2", "t3", "t1"], + ); + }); + + it("breaks recency ties deterministically by thread id", () => { + const rows = build({ + unread: new Map([ + ["b", 1], + ["a", 1], + ]), + }); + assert.deepEqual( + rows.map((row) => row.threadHeadId), + ["a", "b"], + ); + }); + + it("carries author, preview, and reply count when the head is loaded", () => { + const rows = build({ + heads: new Map([["t1", { author: "Bart", body: "hi\nthere" }]]), + summaries: new Map([["t1", { descendantCount: 7, lastReplyAt: 5 }]]), + unread: new Map([["t1", 2]]), + }); + assert.equal(rows[0].headAuthor, "Bart"); + assert.equal(rows[0].headPreview, "hi there"); + assert.equal(rows[0].replyCount, 7); + }); + + it("degrades gracefully when the head message is not loaded", () => { + const rows = build({ unread: new Map([["t1", 1]]) }); + assert.equal(rows[0].headAuthor, null); + assert.equal(rows[0].headPreview, null); + assert.equal(rows[0].replyCount, 0); + }); +}); + +describe("areThreadAttentionRowsEqual", () => { + const row = { + threadHeadId: "t1", + headAuthor: "Bart", + headPreview: "hi", + replyCount: 1, + unreadCount: 2, + activeSince: null, + }; + + it("treats field-identical arrays as equal", () => { + assert.ok(areThreadAttentionRowsEqual([row], [{ ...row }])); + }); + + it("detects a changed field", () => { + assert.ok( + !areThreadAttentionRowsEqual([row], [{ ...row, unreadCount: 3 }]), + ); + }); + + it("detects length changes", () => { + assert.ok(!areThreadAttentionRowsEqual([row], [])); + }); +}); + +describe("totalUnreadCount", () => { + it("sums unread across rows", () => { + const base = { + threadHeadId: "t", + headAuthor: null, + headPreview: null, + replyCount: 0, + activeSince: null, + }; + assert.equal( + totalUnreadCount([ + { ...base, threadHeadId: "t1", unreadCount: 2 }, + { ...base, threadHeadId: "t2", unreadCount: 0 }, + { ...base, threadHeadId: "t3", unreadCount: 5 }, + ]), + 7, + ); + }); +}); diff --git a/desktop/src/features/channels/lib/threadAttention.ts b/desktop/src/features/channels/lib/threadAttention.ts new file mode 100644 index 0000000000..4d54263000 --- /dev/null +++ b/desktop/src/features/channels/lib/threadAttention.ts @@ -0,0 +1,126 @@ +/** + * Header-level thread attention derivation: the combined "unread or active" + * list behind the channel header's threads control. Pure functions only — + * first-seen tracking and React wiring live in useThreadAttentionRows. + */ + +export type ThreadAttentionRow = { + threadHeadId: string; + /** Resolved display author of the thread head, when loaded. */ + headAuthor: string | null; + /** Single-line preview of the thread head body, when loaded. */ + headPreview: string | null; + /** Total replies in the thread (descendants, not just direct children). */ + replyCount: number; + /** Unread replies in the thread; 0 for active-only rows. */ + unreadCount: number; + /** Desktop-clock ms when the thread was first seen active; null if idle. */ + activeSince: number | null; +}; + +/** + * Uptime at the coarsest useful fidelity — whole seconds, then whole minutes, + * then whole hours. Never mixes units ("3m", not "3m 12s"). + */ +export function formatCoarseUptime(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + return `${Math.floor(totalMinutes / 60)}h`; +} + +/** Single-line preview of a message body: collapsed whitespace, hard cap. */ +export function buildHeadPreview(body: string): string | null { + const collapsed = body.replace(/\s+/g, " ").trim(); + if (!collapsed) return null; + return collapsed.length > 140 ? `${collapsed.slice(0, 140)}…` : collapsed; +} + +/** + * Merge unread counts and active threads into one attention list. Active + * threads sort first (most recently started on top), then unread threads by + * reply recency. A thread that is both active and unread appears once, in the + * active block, carrying its unread count. + */ +export function buildThreadAttentionRows({ + activeSinceByThread, + getHeadMessage, + getThreadSummary, + threadUnreadCounts, +}: { + activeSinceByThread: ReadonlyMap; + getHeadMessage: ( + threadHeadId: string, + ) => { author: string; body: string } | undefined; + getThreadSummary: ( + threadHeadId: string, + ) => { descendantCount: number; lastReplyAt: number | null } | undefined; + threadUnreadCounts: ReadonlyMap; +}): ThreadAttentionRow[] { + const ids = new Set(activeSinceByThread.keys()); + for (const [threadHeadId, count] of threadUnreadCounts) { + if (count > 0) ids.add(threadHeadId); + } + + const rows = [...ids].map((threadHeadId) => { + const head = getHeadMessage(threadHeadId); + const summary = getThreadSummary(threadHeadId); + return { + threadHeadId, + headAuthor: head?.author ?? null, + headPreview: head ? buildHeadPreview(head.body) : null, + replyCount: summary?.descendantCount ?? 0, + unreadCount: threadUnreadCounts.get(threadHeadId) ?? 0, + activeSince: activeSinceByThread.get(threadHeadId) ?? null, + lastReplyAt: summary?.lastReplyAt ?? null, + }; + }); + + rows.sort((left, right) => { + if ((left.activeSince === null) !== (right.activeSince === null)) { + return left.activeSince === null ? 1 : -1; + } + if (left.activeSince !== null && right.activeSince !== null) { + if (left.activeSince !== right.activeSince) { + return right.activeSince - left.activeSince; + } + } + const leftRecency = left.lastReplyAt ?? 0; + const rightRecency = right.lastReplyAt ?? 0; + if (leftRecency !== rightRecency) return rightRecency - leftRecency; + return left.threadHeadId.localeCompare(right.threadHeadId); + }); + + return rows.map(({ lastReplyAt: _lastReplyAt, ...row }) => row); +} + +/** Field-wise equality for the stabilized rows array (see useStableRows). */ +export function areThreadAttentionRowsEqual( + a: readonly ThreadAttentionRow[], + b: readonly ThreadAttentionRow[], +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const left = a[i]; + const right = b[i]; + if ( + left.threadHeadId !== right.threadHeadId || + left.headAuthor !== right.headAuthor || + left.headPreview !== right.headPreview || + left.replyCount !== right.replyCount || + left.unreadCount !== right.unreadCount || + left.activeSince !== right.activeSince + ) { + return false; + } + } + return true; +} + +/** Badge total for the header trigger: unread replies across all threads. */ +export function totalUnreadCount(rows: readonly ThreadAttentionRow[]): number { + let total = 0; + for (const row of rows) total += row.unreadCount; + return total; +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index add8253fc9..a8e441dda4 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -22,6 +22,8 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { AddChannelBotDialog } from "./AddChannelBotDialog"; +import { ChannelThreadsAttentionMenu } from "./ChannelThreadsAttentionMenu"; +import type { ThreadAttentionRow } from "@/features/channels/lib/threadAttention"; type ChannelMembersBarProps = { channel: Channel; @@ -29,7 +31,10 @@ type ChannelMembersBarProps = { isAddBotOpen?: boolean; onAddBotOpenChange?: (open: boolean) => void; onManageChannel: () => void; + onSelectAttentionThread?: (threadHeadId: string) => void; onToggleMembers: () => void; + threadAttentionRows?: readonly ThreadAttentionRow[]; + threadAttentionUnreadCount?: number; variant?: "inline" | "compact"; }; @@ -39,7 +44,10 @@ export function ChannelMembersBar({ isAddBotOpen: isAddBotOpenProp, onAddBotOpenChange, onManageChannel, + onSelectAttentionThread, onToggleMembers, + threadAttentionRows, + threadAttentionUnreadCount = 0, variant = "inline", }: ChannelMembersBarProps) { const [uncontrolledAddBotOpen, setUncontrolledAddBotOpen] = @@ -134,43 +142,56 @@ export function ChannelMembersBar({ /> ); + const threadsAttentionMenu = + onSelectAttentionThread && threadAttentionRows ? ( + + ) : null; + const controls = variant === "compact" ? ( - - - - - - - - Members - - {memberCount} - - - {huddleIndicator} - - - Manage channel - - - +
+ {threadsAttentionMenu} + + + + + + + + Members + + {memberCount} + + + {huddleIndicator} + + + Manage channel + + + +
) : (
+ {threadsAttentionMenu} + + + Threads + {rows.length === 0 ? ( +
+ No unread or active threads +
+ ) : ( + + )} +
+ + ); +} + +/** + * Rendered only while the menu is open, so the 1s uptime tick never runs for + * a closed menu. + */ +function ThreadAttentionMenuRows({ + onSelectThread, + rows, +}: { + onSelectThread: (threadHeadId: string) => void; + rows: readonly ThreadAttentionRow[]; +}) { + const hasActiveRow = rows.some((row) => row.activeSince !== null); + const now = useNow(hasActiveRow ? 1_000 : 60_000); + + return ( + <> + {rows.map((row) => ( + onSelectThread(row.threadHeadId)} + > +
+
+ + {row.headAuthor ?? "Thread"} + + {row.activeSince !== null ? ( + + {formatCoarseUptime(now - row.activeSince)} + + ) : null} + {row.unreadCount > 0 ? ( + + {row.unreadCount} + + ) : null} +
+ + {row.headPreview ?? + `${row.replyCount} ${row.replyCount === 1 ? "reply" : "replies"}`} + +
+
+ ))} + + ); +} diff --git a/desktop/src/features/channels/ui/useThreadAttention.ts b/desktop/src/features/channels/ui/useThreadAttention.ts new file mode 100644 index 0000000000..6ec5f0d334 --- /dev/null +++ b/desktop/src/features/channels/ui/useThreadAttention.ts @@ -0,0 +1,89 @@ +import * as React from "react"; + +import { + areThreadAttentionRowsEqual, + buildThreadAttentionRows, + totalUnreadCount, + type ThreadAttentionRow, +} from "@/features/channels/lib/threadAttention"; +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; + +/** + * Attention rows for the channel header's threads control: every thread that + * is unread or has an agent actively working in it, one combined list. + * + * "Active" is derived from thread-scoped bot typing. The uptime anchor is the + * first render this hook saw the thread active (a session-local first-seen + * map, pruned when typing lapses) — the honest floor for a signal that + * carries no start time of its own. Typing has an 8s TTL, so an agent that + * goes quiet between tool calls briefly drops out and re-anchors; the coarse + * Ns/Nm/Nh display keeps that wobble from mattering much. + */ +export function useThreadAttentionRows({ + botTypingEntries, + threadSummaries, + threadUnreadCounts, + timelineMessages, +}: { + botTypingEntries: TypingIndicatorEntry[]; + threadSummaries: ReadonlyMap; + threadUnreadCounts: ReadonlyMap; + timelineMessages: TimelineMessage[]; +}): { rows: readonly ThreadAttentionRow[]; unreadCount: number } { + // Stable key of active thread heads so the first-seen map only rebuilds + // when the SET changes — never on unrelated typing-entry reference churn. + const activeThreadKey = React.useMemo(() => { + const ids = new Set(); + for (const entry of botTypingEntries) { + if (entry.threadHeadId !== null) ids.add(entry.threadHeadId); + } + return [...ids].sort().join(","); + }, [botTypingEntries]); + + const firstSeenRef = React.useRef(new Map()); + const activeSinceByThread = React.useMemo(() => { + const idSet = new Set(activeThreadKey ? activeThreadKey.split(",") : []); + const firstSeen = firstSeenRef.current; + for (const id of idSet) { + if (!firstSeen.has(id)) firstSeen.set(id, Date.now()); + } + for (const id of [...firstSeen.keys()]) { + if (!idSet.has(id)) firstSeen.delete(id); + } + return new Map(firstSeen); + }, [activeThreadKey]); + + const messageById = React.useMemo( + () => new Map(timelineMessages.map((message) => [message.id, message])), + [timelineMessages], + ); + + const rawRows = React.useMemo( + () => + buildThreadAttentionRows({ + activeSinceByThread, + getHeadMessage: (threadHeadId) => messageById.get(threadHeadId), + getThreadSummary: (threadHeadId) => threadSummaries.get(threadHeadId), + threadUnreadCounts, + }), + [activeSinceByThread, messageById, threadSummaries, threadUnreadCounts], + ); + + // Field-wise stabilization: busy channels recompute the timeline (and thus + // rawRows) constantly, but the header memo and menu rows should only see a + // new reference when a row actually changed. + const stableRowsRef = React.useRef(rawRows); + if ( + stableRowsRef.current !== rawRows && + !areThreadAttentionRowsEqual(stableRowsRef.current, rawRows) + ) { + stableRowsRef.current = rawRows; + } + const rows = stableRowsRef.current; + + const unreadCount = React.useMemo(() => totalUnreadCount(rows), [rows]); + + return { rows, unreadCount }; +} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 078b0aff61..014f556df6 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -173,6 +173,33 @@ export function useChannelPaneHandlers({ ], ); + /** + * Open a thread from the header attention menu: always opens (no toggle) + * and scroll-focuses the thread head. Distinct from handleOpenThread, whose + * toggle semantics would close an already-open thread on re-select. + */ + const handleOpenThreadFocused = React.useCallback( + (threadHeadId: string) => { + deferPanelState(() => { + onOptimisticOpenThreadHeadIdChange(threadHeadId); + setOpenThreadHeadId(threadHeadId); + setThreadReplyTargetId(threadHeadId); + setThreadScrollTargetId(threadHeadId); + setExpandedThreadReplyIds(new Set()); + }); + setEditTargetId(null); + }, + [ + deferPanelState, + onOptimisticOpenThreadHeadIdChange, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setThreadReplyTargetId, + setThreadScrollTargetId, + ], + ); + const handleSelectThreadReplyTarget = React.useCallback( (message: { id: string }) => { if (threadReplyTargetIdRef.current === message.id) { @@ -325,6 +352,7 @@ export function useChannelPaneHandlers({ handleEditSave, handleExpandThreadReplies, handleOpenThread, + handleOpenThreadFocused, handleSendMessage, handleSendThreadReply, handleSelectThreadReplyTarget,