feat(desktop): add header-level threads attention menu

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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-07 00:07:31 -07:00
co-authored by Taylor Ho
parent e20408e053
commit 42e81166d5
8 changed files with 662 additions and 33 deletions
@@ -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,
);
});
});
@@ -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<string, number>;
getHeadMessage: (
threadHeadId: string,
) => { author: string; body: string } | undefined;
getThreadSummary: (
threadHeadId: string,
) => { descendantCount: number; lastReplyAt: number | null } | undefined;
threadUnreadCounts: ReadonlyMap<string, number>;
}): ThreadAttentionRow[] {
const ids = new Set<string>(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;
}
@@ -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 ? (
<ChannelThreadsAttentionMenu
onSelectThread={onSelectAttentionThread}
rows={threadAttentionRows}
unreadCount={threadAttentionUnreadCount}
/>
) : null;
const controls =
variant === "compact" ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-[6px]">
{threadsAttentionMenu}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="flex items-center gap-[6px]">
{threadsAttentionMenu}
<Button
aria-label={`View channel members (${memberCount})`}
className="h-8 px-2.5"
@@ -76,6 +76,7 @@ import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelUnreadState } from "./useChannelUnreadState";
import { useThreadAttentionRows } from "./useThreadAttention";
import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
@@ -496,6 +497,7 @@ export function ChannelScreen({
handleEditSave,
handleExpandThreadReplies,
handleOpenThread,
handleOpenThreadFocused,
handleSendMessage,
handleSendThreadReply,
handleSelectThreadReplyTarget,
@@ -775,6 +777,18 @@ export function ChannelScreen({
[],
);
const { rows: threadAttentionRows, unreadCount: threadAttentionUnreadCount } =
useThreadAttentionRows({
botTypingEntries,
threadSummaries,
threadUnreadCounts,
timelineMessages,
});
// Forums render a different pane without the thread panel, so the header
// attention control would be a dead affordance there — gate it off.
const isForumChannel = activeChannel?.channelType === "forum";
const channelHeader = React.useMemo(
() => (
<ChannelScreenHeader
@@ -792,8 +806,13 @@ export function ChannelScreen({
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={handleManageChannel}
onSelectAttentionThread={
isForumChannel ? undefined : handleOpenThreadFocused
}
onToggleMembers={handleToggleMembers}
showHeaderContent={!isSinglePanelView}
threadAttentionRows={isForumChannel ? undefined : threadAttentionRows}
threadAttentionUnreadCount={threadAttentionUnreadCount}
transparentChrome={activeChannel?.channelType !== "forum"}
/>
),
@@ -811,8 +830,12 @@ export function ChannelScreen({
joinChannelMutation.isPending,
joinChannelMutation.mutateAsync,
handleManageChannel,
handleOpenThreadFocused,
handleToggleMembers,
isForumChannel,
isSinglePanelView,
threadAttentionRows,
threadAttentionUnreadCount,
],
);
@@ -15,6 +15,7 @@ import {
} from "@/features/profile/ui/ProfileAvatarWithStatus";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import type { ThreadAttentionRow } from "@/features/channels/lib/threadAttention";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const DM_HEADER_AVATAR_SIZE = 32;
@@ -40,7 +41,10 @@ type ChannelScreenHeaderProps = {
onAddBotOpenChange?: (open: boolean) => void;
onJoinChannel?: () => Promise<void>;
onManageChannel: () => void;
onSelectAttentionThread?: (threadHeadId: string) => void;
onToggleMembers: () => void;
threadAttentionRows?: readonly ThreadAttentionRow[];
threadAttentionUnreadCount?: number;
};
export function ChannelScreenHeader({
@@ -60,7 +64,10 @@ export function ChannelScreenHeader({
transparentChrome = false,
onJoinChannel,
onManageChannel,
onSelectAttentionThread,
onToggleMembers,
threadAttentionRows,
threadAttentionUnreadCount,
}: ChannelScreenHeaderProps) {
const isGroupDm =
activeChannel?.channelType === "dm" &&
@@ -90,7 +97,10 @@ export function ChannelScreenHeader({
isAddBotOpen={isAddBotOpen}
onAddBotOpenChange={onAddBotOpenChange}
onManageChannel={onManageChannel}
onSelectAttentionThread={onSelectAttentionThread}
onToggleMembers={onToggleMembers}
threadAttentionRows={threadAttentionRows}
threadAttentionUnreadCount={threadAttentionUnreadCount}
variant={actionsVariant}
/>
)
@@ -0,0 +1,136 @@
import { ReceiptText } from "lucide-react";
import {
formatCoarseUptime,
type ThreadAttentionRow,
} from "@/features/channels/lib/threadAttention";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { useNow } from "@/shared/lib/useNow";
const MAX_BADGE_COUNT = 99;
/**
* Header-level threads attention control: a ReceiptText trigger badged with
* the channel's unread-reply total, opening one combined list of threads that
* are unread or have an agent actively working. Selecting a row opens the
* thread panel focused on the thread head.
*/
export function ChannelThreadsAttentionMenu({
onSelectThread,
rows,
unreadCount,
}: {
onSelectThread: (threadHeadId: string) => void;
rows: readonly ThreadAttentionRow[];
unreadCount: number;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label={
unreadCount > 0
? `Threads needing attention (${unreadCount} unread)`
: "Threads needing attention"
}
className="relative"
data-testid="channel-threads-attention-trigger"
size="icon"
type="button"
variant="outline"
>
<ReceiptText />
{unreadCount > 0 ? (
<span
className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground tabular-nums"
data-testid="channel-threads-attention-badge"
>
{unreadCount > MAX_BADGE_COUNT
? `${MAX_BADGE_COUNT}+`
: unreadCount}
</span>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80"
data-testid="channel-threads-attention-menu"
>
<DropdownMenuLabel>Threads</DropdownMenuLabel>
{rows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No unread or active threads
</div>
) : (
<ThreadAttentionMenuRows
onSelectThread={onSelectThread}
rows={rows}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* 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) => (
<DropdownMenuItem
data-testid={`channel-threads-attention-item-${row.threadHeadId}`}
key={row.threadHeadId}
onSelect={() => onSelectThread(row.threadHeadId)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{row.headAuthor ?? "Thread"}
</span>
{row.activeSince !== null ? (
<span
className="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums text-primary motion-safe:animate-pulse"
data-testid="channel-threads-attention-uptime"
>
{formatCoarseUptime(now - row.activeSince)}
</span>
) : null}
{row.unreadCount > 0 ? (
<span
className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground tabular-nums"
data-testid="channel-threads-attention-unread"
>
{row.unreadCount}
</span>
) : null}
</div>
<span className="truncate text-xs text-muted-foreground">
{row.headPreview ??
`${row.replyCount} ${row.replyCount === 1 ? "reply" : "replies"}`}
</span>
</div>
</DropdownMenuItem>
))}
</>
);
}
@@ -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<string, ChannelWindowThreadSummary>;
threadUnreadCounts: ReadonlyMap<string, number>;
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<string>();
for (const entry of botTypingEntries) {
if (entry.threadHeadId !== null) ids.add(entry.threadHeadId);
}
return [...ids].sort().join(",");
}, [botTypingEntries]);
const firstSeenRef = React.useRef(new Map<string, number>());
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 };
}
@@ -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,