mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
e20408e053
commit
42e81166d5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user