Improve thread branch display (#1166)

This commit is contained in:
klopez4212
2026-06-22 10:05:37 -07:00
committed by GitHub
parent a96d173e99
commit 342a251a91
11 changed files with 1345 additions and 219 deletions
@@ -831,6 +831,9 @@ export const ChannelPane = React.memo(function ChannelPane({
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadUnreadCount={threadUnreadCounts?.get(
threadHeadMessage.id,
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
@@ -7,6 +7,8 @@ import {
buildThreadPanelData,
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
buildThreadSummaryFromVisibleEntries,
hasNestedThreadBranches,
shouldRenderUnreadDivider,
} from "./threadPanel.ts";
@@ -64,7 +66,7 @@ test("buildMainTimelineEntries includes broadcast replies", () => {
);
});
test("buildThreadPanelData keeps direct comments unindented", () => {
test("buildThreadPanelData connects direct comments to the thread head", () => {
const root = message({ id: "root", createdAt: 1 });
const directComment = message({
id: "direct-comment",
@@ -99,12 +101,198 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
depth: entry.message.depth,
})),
[
{ id: "direct-comment", depth: 0 },
{ id: "nested-reply", depth: 1 },
{ id: "direct-comment", depth: 1 },
{ id: "nested-reply", depth: 2 },
],
);
});
test("buildThreadPanelData hides collapsed summaries for expanded replies", () => {
const root = message({ id: "root", createdAt: 1 });
const branch = message({
id: "branch",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
tags: [["e", "root", "", "reply"]],
});
const child = message({
id: "child",
createdAt: 3,
parentId: "branch",
rootId: "root",
depth: 2,
tags: [
["e", "root", "", "root"],
["e", "branch", "", "reply"],
],
});
const collapsed = buildThreadPanelData(
[root, branch, child],
"root",
"root",
new Set(),
);
const expanded = buildThreadPanelData(
[root, branch, child],
"root",
"root",
new Set(["branch"]),
);
assert.equal(collapsed.visibleReplies[0].summary?.replyCount, 1);
assert.equal(expanded.visibleReplies[0].summary, null);
});
test("buildThreadSummaryFromVisibleEntries counts visible rows and hidden descendants", () => {
const root = message({ id: "root", createdAt: 1 });
const branch = message({
id: "branch",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
pubkey: "branch-author",
author: "Branch Author",
});
const child = message({
id: "child",
createdAt: 3,
parentId: "branch",
rootId: "root",
depth: 2,
pubkey: "child-author",
author: "Child Author",
});
const grandchild = message({
id: "grandchild",
createdAt: 4,
parentId: "child",
rootId: "root",
depth: 3,
pubkey: "grandchild-author",
author: "Grandchild Author",
});
const sibling = message({
id: "sibling",
createdAt: 5,
parentId: "root",
rootId: "root",
depth: 1,
pubkey: "sibling-author",
author: "Sibling Author",
});
const collapsed = buildThreadPanelData(
[root, branch, child, grandchild, sibling],
"root",
"root",
new Set(),
);
const expanded = buildThreadPanelData(
[root, branch, child, grandchild, sibling],
"root",
"root",
new Set(["branch"]),
);
for (const entries of [collapsed.visibleReplies, expanded.visibleReplies]) {
const summary = buildThreadSummaryFromVisibleEntries("root", entries);
assert.equal(summary?.threadHeadId, "root");
assert.equal(summary?.replyCount, 4);
assert.equal(summary?.lastReplyAt, 5);
assert.equal(summary?.participants.length, 3);
assert.ok(
summary?.participants.some(
(participant) => participant.id === "sibling-author",
),
);
}
});
test("hasNestedThreadBranches returns false for flat direct replies", () => {
const root = message({ id: "root", createdAt: 1 });
const first = message({
id: "first",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
});
const second = message({
id: "second",
createdAt: 3,
parentId: "root",
rootId: "root",
depth: 1,
});
const panelData = buildThreadPanelData(
[root, first, second],
"root",
"root",
new Set(),
);
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false);
});
test("hasNestedThreadBranches returns true for visible nested replies", () => {
const root = message({ id: "root", createdAt: 1 });
const branch = message({
id: "branch",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
});
const child = message({
id: "child",
createdAt: 3,
parentId: "branch",
rootId: "root",
depth: 2,
});
const panelData = buildThreadPanelData(
[root, branch, child],
"root",
"root",
new Set(["branch"]),
);
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true);
});
test("hasNestedThreadBranches returns true for collapsed nested replies", () => {
const root = message({ id: "root", createdAt: 1 });
const branch = message({
id: "branch",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
});
const child = message({
id: "child",
createdAt: 3,
parentId: "branch",
rootId: "root",
depth: 2,
});
const panelData = buildThreadPanelData(
[root, branch, child],
"root",
"root",
new Set(),
);
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true);
});
test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => {
// Fresh/never-read channel: the first message IS the first unread, nothing
// above it to separate from.
@@ -41,6 +41,12 @@ export type ThreadPanelIndex = {
const MAX_SUMMARY_PARTICIPANTS = 3;
type SummaryParticipantCandidate = {
index: number;
participant: TimelineThreadSummaryParticipant;
timestamp: number;
};
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
return {
...message,
@@ -217,6 +223,97 @@ function buildSummaryForDirectReplies(
};
}
function participantFromMessage(
message: TimelineMessage,
): TimelineThreadSummaryParticipant {
return {
id: message.pubkey ?? message.id,
author: message.author,
avatarUrl: message.avatarUrl ?? null,
};
}
export function buildThreadSummaryFromVisibleEntries(
threadHeadId: string,
entries: readonly MainTimelineEntry[],
): TimelineThreadSummary | null {
let replyCount = 0;
let lastReplyAt: number | null = null;
const participantCandidates: SummaryParticipantCandidate[] = [];
const addParticipantCandidate = (
participant: TimelineThreadSummaryParticipant,
timestamp: number,
) => {
participantCandidates.push({
index: participantCandidates.length,
participant,
timestamp,
});
};
for (const entry of entries) {
replyCount += 1;
lastReplyAt = Math.max(lastReplyAt ?? 0, entry.message.createdAt);
addParticipantCandidate(
participantFromMessage(entry.message),
entry.message.createdAt,
);
if (entry.summary) {
replyCount += entry.summary.replyCount;
if (entry.summary.lastReplyAt != null) {
lastReplyAt = Math.max(lastReplyAt ?? 0, entry.summary.lastReplyAt);
}
const summaryTimestamp =
entry.summary.lastReplyAt ?? entry.message.createdAt;
for (const participant of entry.summary.participants) {
addParticipantCandidate(participant, summaryTimestamp);
}
}
}
if (replyCount === 0) {
return null;
}
const recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[] = [];
for (const candidate of [...participantCandidates].sort((left, right) => {
if (left.timestamp !== right.timestamp) {
return right.timestamp - left.timestamp;
}
return right.index - left.index;
})) {
if (
recentParticipantsNewestFirst.some(
(participant) => participant.id === candidate.participant.id,
)
) {
continue;
}
recentParticipantsNewestFirst.push(candidate.participant);
if (recentParticipantsNewestFirst.length >= MAX_SUMMARY_PARTICIPANTS) {
break;
}
}
return {
threadHeadId,
replyCount,
lastReplyAt,
participants: recentParticipantsNewestFirst.reverse(),
};
}
export function hasNestedThreadBranches(entries: readonly MainTimelineEntry[]) {
return entries.some(
(entry) => entry.message.depth > 1 || entry.summary !== null,
);
}
function appendExpandedReplies(params: {
entries: MainTimelineEntry[];
parentId: string;
@@ -236,15 +333,15 @@ function appendExpandedReplies(params: {
const directReplies = directChildrenByParentId.get(parentId) ?? [];
for (const reply of directReplies) {
const isExpanded = expandedReplyIds.has(reply.id);
entries.push({
message: normalizeInlineReplyMessage(reply, depth),
summary: buildSummaryForDirectReplies(
reply.id,
descendantStatsByMessageId,
),
summary: isExpanded
? null
: buildSummaryForDirectReplies(reply.id, descendantStatsByMessageId),
});
if (expandedReplyIds.has(reply.id)) {
if (isExpanded) {
appendExpandedReplies({
entries,
parentId: reply.id,
@@ -274,7 +371,7 @@ function buildVisibleThreadReplies(params: {
appendExpandedReplies({
entries,
parentId: openThreadHeadId,
depth: 0,
depth: 1,
directChildrenByParentId,
descendantStatsByMessageId,
expandedReplyIds,
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getThreadReplyAvatarCenterPx,
getThreadReplyAvatarCenterYPx,
getThreadReplyConnectorLayout,
getThreadReplyDescendantRailStartYPx,
getThreadReplyIndentPx,
} from "./threadTreeLayout.ts";
test("getThreadReplyIndentPx aligns child avatars to parent text columns", () => {
assert.equal(getThreadReplyIndentPx(0), 0);
assert.equal(getThreadReplyIndentPx(1), 50);
assert.equal(getThreadReplyIndentPx(2), 100);
assert.equal(getThreadReplyIndentPx(3), 150);
});
test("avatar center helpers expose the rail anchor points", () => {
assert.equal(getThreadReplyAvatarCenterPx(0), 32);
assert.equal(getThreadReplyAvatarCenterPx(1), 82);
assert.equal(getThreadReplyAvatarCenterYPx(), 28);
assert.equal(getThreadReplyDescendantRailStartYPx(), 52);
});
test("getThreadReplyConnectorLayout stops before the child avatar edge", () => {
assert.equal(getThreadReplyConnectorLayout(0), null);
assert.deepEqual(getThreadReplyConnectorLayout(1), {
childOffsetPx: 82,
heightPx: 28,
parentOffsetPx: 32,
widthPx: 26,
});
assert.deepEqual(getThreadReplyConnectorLayout(2), {
childOffsetPx: 132,
heightPx: 28,
parentOffsetPx: 82,
widthPx: 26,
});
});
test("getThreadReplyConnectorLayout clamps very deep replies to the visible rail", () => {
assert.deepEqual(getThreadReplyConnectorLayout(99), {
childOffsetPx: 332,
heightPx: 28,
parentOffsetPx: 282,
widthPx: 26,
});
});
@@ -0,0 +1,71 @@
const THREAD_REPLY_MAX_VISIBLE_DEPTH = 6;
const THREAD_REPLY_AVATAR_SIZE_PX = 40;
const THREAD_REPLY_ROW_CONTENT_INSET_PX = 12;
const THREAD_REPLY_ROW_CONTENT_GAP_PX = 10;
const THREAD_REPLY_ROW_PADDING_TOP_PX = 8;
const THREAD_REPLY_AVATAR_RADIUS_PX = THREAD_REPLY_AVATAR_SIZE_PX / 2;
const THREAD_REPLY_AVATAR_LINE_GAP_PX = 4;
export const THREAD_REPLY_BODY_OFFSET_PX =
THREAD_REPLY_ROW_CONTENT_INSET_PX +
THREAD_REPLY_AVATAR_SIZE_PX +
THREAD_REPLY_ROW_CONTENT_GAP_PX;
export const THREAD_REPLY_ROOT_INDENT_PX =
THREAD_REPLY_BODY_OFFSET_PX - THREAD_REPLY_ROW_CONTENT_INSET_PX;
export const THREAD_REPLY_NESTED_INDENT_PX = THREAD_REPLY_ROOT_INDENT_PX;
export const THREAD_REPLY_LINE_WIDTH_PX = 1.5;
const THREAD_REPLY_AVATAR_CENTER_OFFSET_PX =
THREAD_REPLY_ROW_CONTENT_INSET_PX + THREAD_REPLY_AVATAR_SIZE_PX / 2;
const THREAD_REPLY_AVATAR_CENTER_Y_PX =
THREAD_REPLY_ROW_PADDING_TOP_PX + THREAD_REPLY_AVATAR_SIZE_PX / 2;
function clampVisibleDepth(depth: number) {
return Math.min(Math.max(depth, 0), THREAD_REPLY_MAX_VISIBLE_DEPTH);
}
export function getThreadReplyIndentPx(depth: number) {
const visibleDepth = clampVisibleDepth(depth);
return visibleDepth > 0
? THREAD_REPLY_ROOT_INDENT_PX +
(visibleDepth - 1) * THREAD_REPLY_NESTED_INDENT_PX
: 0;
}
export function getThreadReplyAvatarCenterPx(depth: number) {
return getThreadReplyIndentPx(depth) + THREAD_REPLY_AVATAR_CENTER_OFFSET_PX;
}
export function getThreadReplyAvatarCenterYPx() {
return THREAD_REPLY_AVATAR_CENTER_Y_PX;
}
export function getThreadReplyDescendantRailStartYPx() {
return (
THREAD_REPLY_AVATAR_CENTER_Y_PX +
THREAD_REPLY_AVATAR_RADIUS_PX +
THREAD_REPLY_AVATAR_LINE_GAP_PX
);
}
export function getThreadReplyConnectorLayout(depth: number) {
const visibleDepth = clampVisibleDepth(depth);
if (visibleDepth === 0) {
return null;
}
const parentOffsetPx = getThreadReplyAvatarCenterPx(visibleDepth - 1);
const childOffsetPx = getThreadReplyAvatarCenterPx(visibleDepth);
const childEdgeOffsetPx =
childOffsetPx -
THREAD_REPLY_AVATAR_RADIUS_PX -
THREAD_REPLY_AVATAR_LINE_GAP_PX;
return {
childOffsetPx,
heightPx: THREAD_REPLY_AVATAR_CENTER_Y_PX,
parentOffsetPx,
widthPx: Math.max(0, childEdgeOffsetPx - parentOffsetPx),
};
}
@@ -1,7 +1,7 @@
import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { X } from "lucide-react";
import { CornerUpLeft, Pencil, X } from "lucide-react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
@@ -147,10 +147,7 @@ export function MessageComposer({
const previousDraftKeyRef = React.useRef<string | null>(null);
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
effectiveDraftKeyRef.current = effectiveDraftKey;
// Snapshot of composer state at the moment we enter edit mode (text body
// + draft attachments) so the user's pre-edit work isn't lost when the
// composer is hijacked for editing. Restored on edit-cancel/exit. `null`
// while not in edit mode.
// Snapshot composer state before edit mode so cancel can restore it.
const preEditSnapshotRef = React.useRef<{
content: string;
pendingImeta: ImetaMedia[];
@@ -828,9 +825,63 @@ export function MessageComposer({
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-5 bg-background"
/>
<div className="relative flex w-full flex-col gap-3">
<div className="relative flex w-full flex-col gap-0">
{editTarget ? (
<div
className="relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
data-testid="edit-target"
>
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Editing message
</p>
</div>
{onCancelEdit ? (
<Button
aria-label="Cancel edit"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelEdit}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : replyTarget ? (
<div
className="relative z-0 -mb-4 flex transform-gpu items-start gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
data-testid="reply-target"
>
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Replying to {replyTarget.author}
</p>
{replyTarget.body ? (
<p className="truncate text-muted-foreground/80">
{replyTarget.body}
</p>
) : null}
</div>
{onCancelReply ? (
<Button
aria-label="Cancel reply"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}
<form
className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
className="relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
data-testid="message-composer"
onDragEnter={ownsDropZone ? media.handleDragEnter : undefined}
onDragLeave={ownsDropZone ? media.handleDragLeave : undefined}
@@ -870,57 +921,6 @@ export function MessageComposer({
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
{editTarget ? (
<div
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2"
data-testid="edit-target"
>
<div className="min-w-0">
<p className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Editing message
</p>
<p className="truncate text-sm text-foreground/80">
{editTarget.body}
</p>
</div>
<Button
className="shrink-0"
onClick={onCancelEdit}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
) : replyTarget ? (
<div
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2"
data-testid="reply-target"
>
<div className="min-w-0">
<p className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Replying to {replyTarget.author}
</p>
<p className="truncate text-sm text-foreground/80">
{replyTarget.body}
</p>
</div>
{onCancelReply ? (
<Button
aria-label="Cancel reply"
className="h-7 w-7 shrink-0 px-0"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
+253 -32
View File
@@ -6,6 +6,14 @@ import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
import {
getThreadReplyAvatarCenterPx,
getThreadReplyAvatarCenterYPx,
getThreadReplyDescendantRailStartYPx,
getThreadReplyConnectorLayout,
getThreadReplyIndentPx,
THREAD_REPLY_LINE_WIDTH_PX,
} from "@/features/messages/lib/threadTreeLayout";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -27,18 +35,33 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
const MESSAGE_TEXT_OFFSET_PX = 54;
const NESTED_REPLY_OFFSET_PX = 28;
export type ThreadDepthGuideAction = {
active?: boolean;
depth: number;
label: string;
message: TimelineMessage;
};
export const MessageRow = React.memo(
function MessageRow({
channelId = null,
collapseDepthGuideActions,
connectDescendants = false,
depthGuideDepths,
highlighted = false,
highlightDescendantRail = false,
highlightReplyConnector = false,
highlightThreadLineDepths,
hoverBackground = true,
actionBarPlacement = "floating",
collapseDescendantsLabel,
isFollowingThread,
layoutVariant = "default",
message,
onCollapseDepthGuide,
onCollapseDepthGuideHoverChange,
onCollapseDescendants,
onCollapseDescendantsHoverChange,
onDelete,
onEdit,
onFollowThread,
@@ -54,12 +77,29 @@ export const MessageRow = React.memo(
}: {
agentPubkeys?: ReadonlySet<string>;
channelId?: string | null;
collapseDepthGuideActions?: ReadonlyArray<ThreadDepthGuideAction>;
connectDescendants?: boolean;
depthGuideDepths?: ReadonlyArray<number>;
highlighted?: boolean;
highlightDescendantRail?: boolean;
highlightReplyConnector?: boolean;
highlightThreadLineDepths?: ReadonlyArray<number>;
hoverBackground?: boolean;
actionBarPlacement?: "floating" | "inside";
collapseDescendantsLabel?: string;
isFollowingThread?: boolean;
layoutVariant?: "default" | "thread-reply";
message: TimelineMessage;
onCollapseDepthGuide?: (message: TimelineMessage) => void;
onCollapseDepthGuideHoverChange?: (
message: TimelineMessage,
hovered: boolean,
) => void;
onCollapseDescendants?: (message: TimelineMessage) => void;
onCollapseDescendantsHoverChange?: (
message: TimelineMessage,
hovered: boolean,
) => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
@@ -142,24 +182,63 @@ export const MessageRow = React.memo(
[channels],
);
const visibleDepth = Math.min(message.depth, 6);
const indentPx =
visibleDepth > 0
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
: 0;
const depthGuideOffsets = React.useMemo(() => {
if (visibleDepth === 0) {
return [];
const indentPx = getThreadReplyIndentPx(message.depth);
const descendantGuideOffsetPx = connectDescendants
? getThreadReplyAvatarCenterPx(message.depth)
: null;
const replyConnector = React.useMemo(() => {
return getThreadReplyConnectorLayout(message.depth);
}, [message.depth]);
const depthGuideItems = React.useMemo(() => {
const depths =
depthGuideDepths ??
Array.from({ length: message.depth }, (_, depth) => depth);
return depths.map((depth) => ({
depth,
offset: getThreadReplyAvatarCenterPx(depth),
}));
}, [depthGuideDepths, message.depth]);
const handleCollapseDescendants = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onCollapseDescendants?.(message);
},
[message, onCollapseDescendants],
);
const handleCollapseDescendantsHoverChange = React.useCallback(
(hovered: boolean) => {
onCollapseDescendantsHoverChange?.(message, hovered);
},
[message, onCollapseDescendantsHoverChange],
);
const handleCollapseDepthGuide = React.useCallback(
(
event: React.MouseEvent<HTMLButtonElement>,
targetMessage: TimelineMessage,
) => {
event.preventDefault();
event.stopPropagation();
onCollapseDepthGuide?.(targetMessage);
},
[onCollapseDepthGuide],
);
const handleCollapseDepthGuideHoverChange = React.useCallback(
(targetMessage: TimelineMessage, hovered: boolean) => {
onCollapseDepthGuideHoverChange?.(targetMessage, hovered);
},
[onCollapseDepthGuideHoverChange],
);
const collapseDepthGuideActionsByDepth = React.useMemo(() => {
if (!collapseDepthGuideActions?.length) {
return new Map<number, ThreadDepthGuideAction>();
}
return Array.from({ length: visibleDepth }, (_, index) =>
index === 0
? MESSAGE_TEXT_OFFSET_PX / 2
: MESSAGE_TEXT_OFFSET_PX +
NESTED_REPLY_OFFSET_PX / 2 +
(index - 1) * NESTED_REPLY_OFFSET_PX,
return new Map(
collapseDepthGuideActions.map((action) => [action.depth, action]),
);
}, [visibleDepth]);
}, [collapseDepthGuideActions]);
const getTag = (name: string) =>
message.tags?.find((tag) => tag[0] === name)?.[1];
@@ -359,34 +438,163 @@ export const MessageRow = React.memo(
className="relative"
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
>
{showDepthGuides && depthGuideOffsets.length > 0 ? (
{showDepthGuides && depthGuideItems.length > 0 ? (
<div
aria-hidden
className="pointer-events-none absolute left-0"
aria-hidden={
collapseDepthGuideActionsByDepth.size > 0 ? undefined : true
}
className={cn(
"absolute left-0",
collapseDepthGuideActionsByDepth.size === 0 &&
"pointer-events-none",
)}
style={{
bottom: `${-guideBleedPx}px`,
top: `${-guideBleedPx}px`,
}}
>
{depthGuideOffsets.map((offset, index) => (
<div
className="absolute bottom-0 top-0 border-l border-border/70"
key={`${message.id}-depth-guide-${offset}`}
style={{
left: `${offset}px`,
opacity: index === depthGuideOffsets.length - 1 ? 0.9 : 0.55,
}}
/>
))}
{depthGuideItems.map(({ depth, offset }) => {
const collapseAction =
collapseDepthGuideActionsByDepth.get(depth);
const isHighlighted =
Boolean(collapseAction?.active) ||
Boolean(highlightThreadLineDepths?.includes(depth));
const lineClassName = cn(
"absolute bottom-0 left-1/2 top-0 border-l transition-[border-color]",
isHighlighted
? "border-primary"
: "border-border group-hover/thread-guide:border-primary group-focus-visible/thread-guide:border-primary",
);
if (collapseAction) {
return (
<button
aria-label={collapseAction.label}
className="group/thread-guide absolute bottom-0 top-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full focus-visible:outline-hidden"
data-thread-head-id={collapseAction.message.id}
data-testid="thread-collapse-guide"
key={`${message.id}-depth-guide-${offset}`}
onBlur={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
false,
)
}
onClick={(event) =>
handleCollapseDepthGuide(event, collapseAction.message)
}
onFocus={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
true,
)
}
onMouseEnter={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
true,
)
}
onMouseLeave={() =>
handleCollapseDepthGuideHoverChange(
collapseAction.message,
false,
)
}
style={{ left: `${offset}px` }}
type="button"
>
<span
className={lineClassName}
style={{
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
}}
/>
</button>
);
}
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
isHighlighted ? "border-primary" : "border-border",
)}
key={`${message.id}-depth-guide-${offset}`}
style={{
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
left: `${offset}px`,
}}
/>
);
})}
</div>
) : null}
{showDepthGuides && descendantGuideOffsetPx !== null ? (
<>
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 z-0 border-l transition-[border-color]",
highlightDescendantRail ? "border-primary" : "border-border",
)}
style={{
bottom: `${-guideBleedPx}px`,
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
left: `${descendantGuideOffsetPx}px`,
top: `${getThreadReplyDescendantRailStartYPx()}px`,
}}
/>
{onCollapseDescendants ? (
<button
aria-label={
collapseDescendantsLabel ?? "Collapse replies to this message"
}
className="absolute bottom-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full p-0 focus-visible:outline-hidden"
data-thread-head-id={message.id}
data-testid="thread-collapse-rail"
onBlur={() => handleCollapseDescendantsHoverChange(false)}
onClick={handleCollapseDescendants}
onFocus={() => handleCollapseDescendantsHoverChange(true)}
onMouseEnter={() => handleCollapseDescendantsHoverChange(true)}
onMouseLeave={() => handleCollapseDescendantsHoverChange(false)}
style={{
left: `${descendantGuideOffsetPx}px`,
top: `${getThreadReplyAvatarCenterYPx()}px`,
}}
type="button"
/>
) : null}
</>
) : null}
{showDepthGuides && replyConnector ? (
<div
aria-hidden
className={cn(
"pointer-events-none absolute left-0 top-0 rounded-bl-2xl border-b border-l transition-[border-color]",
highlightReplyConnector ? "border-primary" : "border-border",
)}
style={{
borderBottomWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
height: `${replyConnector.heightPx + guideBleedPx}px`,
left: `${replyConnector.parentOffsetPx}px`,
top: `${-guideBleedPx}px`,
width: `${replyConnector.widthPx}px`,
}}
/>
) : null}
<article
className={cn(
"group/message relative rounded-2xl py-2 transition-colors",
"group/message relative z-10 rounded-2xl transition-colors",
isThreadReplyLayout ? "py-1.5" : "py-2",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: "px-2",
: isThreadReplyLayout
? "mx-1 px-2"
: "px-2",
"flex items-start gap-2.5",
hasActiveReminder ? "bg-blue-500/10" : "",
highlighted
@@ -520,10 +728,23 @@ export const MessageRow = React.memo(
prev.message.tags === next.message.tags &&
prev.message.role === next.message.role &&
prev.message.personaDisplayName === next.message.personaDisplayName &&
prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
prev.connectDescendants === next.connectDescendants &&
prev.depthGuideDepths === next.depthGuideDepths &&
prev.highlightDescendantRail === next.highlightDescendantRail &&
prev.highlighted === next.highlighted &&
prev.highlightReplyConnector === next.highlightReplyConnector &&
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
prev.hoverBackground === next.hoverBackground &&
prev.isFollowingThread === next.isFollowingThread &&
prev.layoutVariant === next.layoutVariant &&
prev.onCollapseDepthGuide === next.onCollapseDepthGuide &&
prev.onCollapseDepthGuideHoverChange ===
next.onCollapseDepthGuideHoverChange &&
prev.onCollapseDescendants === next.onCollapseDescendants &&
prev.onCollapseDescendantsHoverChange ===
next.onCollapseDescendantsHoverChange &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewContext === next.videoReviewContext,
@@ -1,7 +1,11 @@
import * as React from "react";
import { ArrowDown, ArrowLeft, X } from "lucide-react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import {
buildThreadSummaryFromVisibleEntries,
hasNestedThreadBranches,
type MainTimelineEntry,
} from "@/features/messages/lib/threadPanel";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -26,7 +30,7 @@ import {
import { Skeleton } from "@/shared/ui/skeleton";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
@@ -41,7 +45,6 @@ type MessageThreadPanelProps = {
channelName: string;
currentPubkey?: string;
disabled?: boolean;
/** Event id of the first unread reply, or null/undefined if all read. */
firstUnreadReplyId?: string | null;
layout?: "standalone" | "split";
editTarget?: {
@@ -78,7 +81,7 @@ type MessageThreadPanelProps = {
scrollTargetId: string | null;
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
/** Subtree unread counts for collapsed summary rows, keyed by reply id. */
threadUnreadCount?: number;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadTypingPubkeys: string[];
threadHeadVideoReviewContext?: VideoReviewContext;
@@ -89,11 +92,11 @@ type MessageThreadPanelProps = {
onUnfollowThread?: () => void;
};
/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render when a thread opens stays light instead of blocking on the full
* reply list. Must be module-level so its identity never changes. Mirrors
* `EMPTY_MESSAGES` in MessageTimeline. */
/** Stable `useDeferredValue` initial value; mirrors `EMPTY_MESSAGES`. */
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX = -2;
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
@@ -113,6 +116,53 @@ function canManageMessage(
);
}
function hasLaterVisibleSibling(
entries: readonly MainTimelineEntry[],
entryIndex: number,
): boolean {
const depth = entries[entryIndex]?.message.depth;
if (depth == null) {
return false;
}
for (let index = entryIndex + 1; index < entries.length; index += 1) {
const nextDepth = entries[index].message.depth;
if (nextDepth <= depth) {
return nextDepth === depth;
}
}
return false;
}
function getActiveContinuationDepths({
ancestors,
entries,
index,
message,
}: {
ancestors: readonly { index: number; message: TimelineMessage }[];
entries: readonly MainTimelineEntry[];
index: number;
message: TimelineMessage;
}): number[] {
const depths: number[] = [];
for (const ancestor of ancestors) {
const childDepth = ancestor.message.depth + 1;
const pathChild =
message.depth === childDepth
? { index, message }
: ancestors.find((candidate) => candidate.message.depth === childDepth);
if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) {
depths.push(ancestor.message.depth);
}
}
return depths;
}
function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) {
return (
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
@@ -140,7 +190,12 @@ function ThreadComposerSkeleton() {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<div className="relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0">
<div
className={cn(
"relative z-10 shrink-0 bg-transparent pb-2 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
>
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48 max-w-full" />
<div className="mt-4 flex items-center gap-2">
@@ -150,7 +205,12 @@ function ThreadComposerSkeleton() {
</div>
</div>
</div>
<div className="-mt-1 h-7 bg-background px-4 pb-1 pt-0 sm:px-6" />
<div
className={cn(
"-mt-1 h-7 bg-background pb-1 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
/>
</div>
</div>
);
@@ -206,10 +266,18 @@ export function MessageThreadPanelSkeleton({
)}
data-testid="message-thread-loading"
>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head-loading">
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head-loading"
>
<ThreadMessageSkeleton isHead />
</div>
<div className="space-y-2.5 px-3 pb-3 pt-1">
<div
className={cn(
"space-y-2.5 pb-3 pt-1",
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
)}
>
<ThreadMessageSkeleton />
<ThreadMessageSkeleton />
<div className="ml-[58px] flex items-center gap-1.5 pt-0.5">
@@ -300,6 +368,7 @@ export function MessageThreadPanel({
threadHead,
threadHeadVideoReviewContext,
threadReplies,
threadUnreadCount,
threadReplyUnreadCounts,
threadTypingPubkeys,
toolbarExtraActions,
@@ -312,9 +381,16 @@ export function MessageThreadPanel({
// only to satisfy the hook's required ref contract.
const threadTopSentinelRef = React.useRef<HTMLDivElement>(null);
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState<
string | null
>(null);
const [collapsedThreadHeadId, setCollapsedThreadHeadId] = React.useState<
string | null
>(null);
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
const threadHeadId = threadHead?.id ?? null;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
useComposerHeightPadding(
threadBodyRef,
@@ -322,7 +398,41 @@ export function MessageThreadPanel({
isSinglePanelView,
);
const threadHeadId = threadHead?.id ?? null;
const collapseThreadHeadReplies = React.useCallback(() => {
if (!threadHeadId) {
return;
}
setHoveredCollapseBranchId(null);
setCollapsedThreadHeadId(threadHeadId);
}, [threadHeadId]);
const expandThreadHeadReplies = React.useCallback(() => {
setHoveredCollapseBranchId(null);
setCollapsedThreadHeadId(null);
}, []);
const handleCollapseBranchHoverChange = React.useCallback(
(message: TimelineMessage, hovered: boolean) => {
setHoveredCollapseBranchId((current) => {
if (hovered) {
return message.id;
}
return current === message.id ? null : current;
});
},
[],
);
const handleCollapseDepthGuide = React.useCallback(
(message: TimelineMessage) => {
if (message.id === threadHeadId) {
collapseThreadHeadReplies();
return;
}
onExpandReplies(message);
},
[collapseThreadHeadReplies, onExpandReplies, threadHeadId],
);
const composerReplyTarget =
replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id
@@ -333,22 +443,28 @@ export function MessageThreadPanel({
}
: null;
// The thread side pane renders its reply list straight into heavy
// `react-markdown` rows (`MessageRow`), so opening a deep thread would block
// the main thread and the OS would show the busy cursor. Gate the reply render
// behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on
// thread-open light; the heavy list streams in on a deferred, interruptible
// commit. We deliberately drive BOTH the scroll manager and the rendered list
// off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
// this no-tearing guarantee for free by routing through the same
// `useAnchoredScroll` primitive as the main timeline.
const deferredThreadReplies = React.useDeferredValue(
threadReplies,
EMPTY_THREAD_REPLIES,
);
const isRepliesPending = deferredThreadReplies !== threadReplies;
const scrollTargetIsVisibleReply = React.useMemo(
() =>
scrollTargetId !== null &&
scrollTargetId !== threadHeadId &&
deferredThreadReplies.some(
(entry) => entry.message.id === scrollTargetId,
),
[deferredThreadReplies, scrollTargetId, threadHeadId],
);
const isThreadHeadRepliesCollapsed =
collapsedThreadHeadId === threadHeadId && !scrollTargetIsVisibleReply;
React.useLayoutEffect(() => {
if (scrollTargetIsVisibleReply && collapsedThreadHeadId === threadHeadId) {
setCollapsedThreadHeadId(null);
}
}, [collapsedThreadHeadId, scrollTargetIsVisibleReply, threadHeadId]);
// Which of the three states the reply region paints this frame. Delegated to
// a pure helper so the "don't flash empty over an incoming list" rule is
@@ -357,17 +473,129 @@ export function MessageThreadPanel({
deferredThreadReplies.length,
threadReplies.length,
);
const threadHeadSummary = React.useMemo(() => {
if (!threadHeadId) {
return null;
}
return buildThreadSummaryFromVisibleEntries(
threadHeadId,
deferredThreadReplies,
);
}, [deferredThreadReplies, threadHeadId]);
const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed
? threadHeadSummary
: null;
const threadMessages = React.useMemo(
() => deferredThreadReplies.map((entry) => entry.message),
[deferredThreadReplies],
);
const shouldShowThreadBranchGuides = React.useMemo(
() => hasNestedThreadBranches(deferredThreadReplies),
[deferredThreadReplies],
);
const highlightedBranch = React.useMemo(() => {
if (!hoveredCollapseBranchId) {
return null;
}
if (hoveredCollapseBranchId === threadHeadId) {
return {
depth: 0,
endIndex: deferredThreadReplies.length - 1,
id: hoveredCollapseBranchId,
startIndex: -1,
};
}
const startIndex = deferredThreadReplies.findIndex(
(entry) => entry.message.id === hoveredCollapseBranchId,
);
if (startIndex < 0) {
return null;
}
const depth = deferredThreadReplies[startIndex].message.depth;
let endIndex = startIndex;
while (
endIndex + 1 < deferredThreadReplies.length &&
deferredThreadReplies[endIndex + 1].message.depth > depth
) {
endIndex += 1;
}
return {
depth,
endIndex,
id: hoveredCollapseBranchId,
startIndex,
};
}, [deferredThreadReplies, hoveredCollapseBranchId, threadHeadId]);
const threadReplyRenderItems = React.useMemo(() => {
if (!threadHead) {
return [];
}
const ancestorStack: { index: number; message: TimelineMessage }[] = [
{ index: -1, message: threadHead },
];
return deferredThreadReplies.map((entry, index) => {
while (
ancestorStack.length > 0 &&
ancestorStack[ancestorStack.length - 1].message.depth >=
entry.message.depth
) {
ancestorStack.pop();
}
const ancestors = [...ancestorStack];
const continuationDepths = getActiveContinuationDepths({
ancestors,
entries: deferredThreadReplies,
index,
message: entry.message,
});
const collapseDepthGuideAncestors = ancestors.filter((ancestor) =>
continuationDepths.includes(ancestor.message.depth),
);
const collapseDepthGuideActions: ThreadDepthGuideAction[] | undefined =
collapseDepthGuideAncestors.length > 0
? collapseDepthGuideAncestors.map((ancestor) => ({
active:
hoveredCollapseBranchId === ancestor.message.id &&
entry.message.depth === ancestor.message.depth + 1,
depth: ancestor.message.depth,
label:
ancestor.message.id === threadHead.id
? "Collapse thread"
: "Collapse replies",
message: ancestor.message,
}))
: undefined;
const nextEntry = deferredThreadReplies[index + 1];
const connectsToVisibleChild =
nextEntry != null && nextEntry.message.depth > entry.message.depth;
if (connectsToVisibleChild && !entry.summary) {
ancestorStack.push({ index, message: entry.message });
}
return {
collapseDepthGuideActions,
connectsToVisibleChild,
continuationDepths,
entry,
index,
};
});
}, [deferredThreadReplies, hoveredCollapseBranchId, threadHead]);
const { isAtBottom, newMessageCount, onScroll, scrollToBottom } =
useAnchoredScroll({
channelId: threadHeadId,
contentRef: threadContentRef,
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
isLoading: repliesRenderState === "pending",
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
@@ -393,15 +621,35 @@ export function MessageThreadPanel({
>
<div ref={threadContentRef}>
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
data-testid="message-thread-head"
>
<div className="rounded-2xl">
<MessageRow
actionBarPlacement="inside"
agentPubkeys={agentPubkeys}
channelId={channelId}
collapseDescendantsLabel="Collapse thread"
connectDescendants={
shouldShowThreadBranchGuides &&
!isThreadHeadRepliesCollapsed &&
deferredThreadReplies.length > 0
}
highlightDescendantRail={
shouldShowThreadBranchGuides &&
!isThreadHeadRepliesCollapsed &&
highlightedBranch?.id === threadHead.id
}
isFollowingThread={isFollowingThread}
layoutVariant="thread-reply"
message={threadHead}
onCollapseDescendants={
isThreadHeadRepliesCollapsed
? undefined
: collapseThreadHeadReplies
}
onCollapseDescendantsHoverChange={handleCollapseBranchHoverChange}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
@@ -421,67 +669,163 @@ export function MessageThreadPanel({
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
videoReviewContext={threadHeadVideoReviewContext}
/>
</div>
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
data-testid="message-thread-replies"
>
{repliesRenderState === "list" ? (
<div
className="space-y-2.5"
data-render-pending={isRepliesPending ? "true" : undefined}
>
{deferredThreadReplies.map((entry, index) => {
const showUnreadDivider =
index > 0 && entry.message.id === firstUnreadReplyId;
return (
<div
className={cn(
"flex flex-col gap-1",
entry.summary &&
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}
key={entry.message.renderKey ?? entry.message.id}
>
{showUnreadDivider ? <UnreadDivider /> : null}
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
hoverBackground={!entry.summary}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(entry.message, currentPubkey)
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
visibleThreadHeadSummary ? (
<div
className="space-y-0"
data-render-pending={isRepliesPending ? "true" : undefined}
>
<MessageThreadSummaryRow
depth={threadHead.depth}
message={threadHead}
onOpenThread={expandThreadHeadReplies}
summary={visibleThreadHeadSummary}
summaryIndentOffsetPx={THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX}
unreadCount={threadUnreadCount}
/>
</div>
) : (
<div
className="space-y-0"
data-render-pending={isRepliesPending ? "true" : undefined}
>
{threadReplyRenderItems.map((item) => {
const {
collapseDepthGuideActions,
connectsToVisibleChild,
continuationDepths,
entry,
index,
} = item;
const showUnreadDivider =
index > 0 && entry.message.id === firstUnreadReplyId;
const isHighlightedBranchOwner =
highlightedBranch?.id === entry.message.id;
const isInsideHighlightedBranch =
highlightedBranch != null &&
index > highlightedBranch.startIndex &&
index <= highlightedBranch.endIndex;
const isDirectChildOfHighlightedBranch =
isInsideHighlightedBranch &&
highlightedBranch != null &&
index > highlightedBranch.startIndex &&
index <= highlightedBranch.endIndex &&
entry.message.depth === highlightedBranch.depth + 1;
const highlightedLineDepths =
shouldShowThreadBranchGuides &&
isInsideHighlightedBranch &&
highlightedBranch
? [highlightedBranch.depth]
: undefined;
return (
<div
className={cn(
"flex flex-col gap-0",
entry.summary &&
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}
key={entry.message.renderKey ?? entry.message.id}
>
{showUnreadDivider ? <UnreadDivider /> : null}
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
collapseDepthGuideActions={collapseDepthGuideActions}
collapseDescendantsLabel="Collapse replies"
connectDescendants={
shouldShowThreadBranchGuides && connectsToVisibleChild
}
depthGuideDepths={
shouldShowThreadBranchGuides
? continuationDepths
: undefined
}
highlightDescendantRail={
shouldShowThreadBranchGuides &&
isHighlightedBranchOwner &&
connectsToVisibleChild
}
highlightReplyConnector={
shouldShowThreadBranchGuides &&
isDirectChildOfHighlightedBranch
}
highlightThreadLineDepths={highlightedLineDepths}
hoverBackground={!entry.summary}
layoutVariant="thread-reply"
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
unreadCount={threadReplyUnreadCounts?.get(
entry.message.id,
)}
onCollapseDepthGuide={handleCollapseDepthGuide}
onCollapseDepthGuideHoverChange={
handleCollapseBranchHoverChange
}
onCollapseDescendants={
shouldShowThreadBranchGuides &&
connectsToVisibleChild &&
!entry.summary
? onExpandReplies
: undefined
}
onCollapseDescendantsHoverChange={
handleCollapseBranchHoverChange
}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit &&
canManageMessage(entry.message, currentPubkey)
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
/>
) : null}
</div>
);
})}
</div>
{entry.summary ? (
<MessageThreadSummaryRow
collapseDepthGuideActions={collapseDepthGuideActions}
depth={entry.message.depth}
depthGuideDepths={
shouldShowThreadBranchGuides
? continuationDepths
: undefined
}
highlightThreadLineDepths={highlightedLineDepths}
message={entry.message}
onCollapseDepthGuide={handleCollapseDepthGuide}
onCollapseDepthGuideHoverChange={
handleCollapseBranchHoverChange
}
onOpenThread={onExpandReplies}
summary={entry.summary}
summaryIndentOffsetPx={
THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX
}
showDepthGuides={shouldShowThreadBranchGuides}
unreadCount={threadReplyUnreadCounts?.get(
entry.message.id,
)}
/>
) : null}
</div>
);
})}
</div>
)
) : repliesRenderState === "empty" ? (
// Only show the empty state when the thread is GENUINELY empty.
// Keying off `deferredThreadReplies` would flash "No replies" for a
@@ -532,6 +876,7 @@ export function MessageThreadPanel({
channelId={channelId}
channelName={channelName}
channelType={channel?.channelType ?? null}
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
editTarget={editTarget}
@@ -547,7 +892,12 @@ export function MessageThreadPanel({
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
<div
className={cn(
"-mt-1 h-7 bg-background pb-1 pt-0",
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
)}
>
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
{toolbarExtraActions ? (
<div className="shrink-0">{toolbarExtraActions}</div>
@@ -3,13 +3,17 @@ import type {
TimelineThreadSummaryParticipant,
} from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { ThreadDepthGuideAction } from "@/features/messages/ui/MessageRow";
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
import {
getThreadReplyAvatarCenterPx,
getThreadReplyIndentPx,
THREAD_REPLY_BODY_OFFSET_PX,
THREAD_REPLY_LINE_WIDTH_PX,
} from "@/features/messages/lib/threadTreeLayout";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MESSAGE_TEXT_OFFSET_PX = 54;
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX;
const NESTED_REPLY_OFFSET_PX = 28;
function ParticipantAvatar({
participant,
index,
@@ -43,59 +47,145 @@ function ParticipantAvatar({
}
export function MessageThreadSummaryRow({
collapseDepthGuideActions,
depth = 0,
depthGuideDepths,
highlightThreadLineDepths,
message,
onCollapseDepthGuide,
onCollapseDepthGuideHoverChange,
onOpenThread,
showDepthGuides = true,
summary,
summaryIndentOffsetPx = 0,
unreadCount,
}: {
collapseDepthGuideActions?: ReadonlyArray<ThreadDepthGuideAction>;
depth?: number;
depthGuideDepths?: ReadonlyArray<number>;
highlightThreadLineDepths?: ReadonlyArray<number>;
message: TimelineMessage;
onCollapseDepthGuide?: (message: TimelineMessage) => void;
onCollapseDepthGuideHoverChange?: (
message: TimelineMessage,
hovered: boolean,
) => void;
onOpenThread: (message: TimelineMessage) => void;
showDepthGuides?: boolean;
summary: TimelineThreadSummary;
summaryIndentOffsetPx?: number;
unreadCount?: number;
}) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
const indentPx =
visibleDepth > 0
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
: 0;
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
const indentPx = getThreadReplyIndentPx(depth);
const marginLeftPx =
indentPx + THREAD_REPLY_BODY_OFFSET_PX + summaryIndentOffsetPx;
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
const summaryAriaLabel = summary.lastReplyAt
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}`
: `View thread with ${summary.replyCount} ${replyLabel}`;
const depthGuideOffsets =
visibleDepth === 0
? []
: Array.from({ length: visibleDepth }, (_, index) =>
index === 0
? MESSAGE_TEXT_OFFSET_PX / 2
: MESSAGE_TEXT_OFFSET_PX +
NESTED_REPLY_OFFSET_PX / 2 +
(index - 1) * NESTED_REPLY_OFFSET_PX,
);
const guideDepths = depthGuideDepths
? [...depthGuideDepths]
: Array.from({ length: depth }, (_, index) => index);
const depthGuideItems = guideDepths.map((guideDepth) => ({
depth: guideDepth,
offset: getThreadReplyAvatarCenterPx(guideDepth),
}));
const collapseDepthGuideActionsByDepth = new Map(
collapseDepthGuideActions?.map((action) => [action.depth, action]) ?? [],
);
return (
<div className="relative pb-1 pt-0.5">
{showDepthGuides && depthGuideOffsets.length > 0 ? (
{showDepthGuides && depthGuideItems.length > 0 ? (
<div
aria-hidden
className="pointer-events-none absolute left-0"
aria-hidden={
collapseDepthGuideActionsByDepth.size > 0 ? undefined : true
}
className={cn(
"absolute left-0",
collapseDepthGuideActionsByDepth.size === 0 &&
"pointer-events-none",
)}
style={{ bottom: "-4px", top: "-4px" }}
>
{depthGuideOffsets.map((offset, index) => (
<div
className="absolute bottom-0 top-0 border-l border-border/70"
key={`${message.id}-summary-depth-guide-${offset}`}
style={{
left: `${offset}px`,
opacity: index === depthGuideOffsets.length - 1 ? 0.9 : 0.55,
}}
/>
))}
{depthGuideItems.map(({ depth: guideDepth, offset }) => {
const collapseAction =
collapseDepthGuideActionsByDepth.get(guideDepth);
const isHighlighted =
Boolean(collapseAction?.active) ||
Boolean(highlightThreadLineDepths?.includes(guideDepth));
const lineClassName = cn(
"absolute bottom-0 left-1/2 top-0 border-l transition-[border-color]",
isHighlighted
? "border-primary"
: "border-border group-hover/thread-guide:border-primary group-focus-visible/thread-guide:border-primary",
);
if (collapseAction) {
return (
<button
aria-label={collapseAction.label}
className="group/thread-guide absolute bottom-0 top-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full focus-visible:outline-hidden"
data-thread-head-id={collapseAction.message.id}
data-testid="thread-collapse-guide"
key={`${message.id}-summary-depth-guide-${offset}`}
onBlur={() =>
onCollapseDepthGuideHoverChange?.(
collapseAction.message,
false,
)
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCollapseDepthGuide?.(collapseAction.message);
}}
onFocus={() =>
onCollapseDepthGuideHoverChange?.(
collapseAction.message,
true,
)
}
onMouseEnter={() =>
onCollapseDepthGuideHoverChange?.(
collapseAction.message,
true,
)
}
onMouseLeave={() =>
onCollapseDepthGuideHoverChange?.(
collapseAction.message,
false,
)
}
style={{ left: `${offset}px` }}
type="button"
>
<span
className={lineClassName}
style={{
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
}}
/>
</button>
);
}
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
isHighlighted ? "border-primary" : "border-border",
)}
key={`${message.id}-summary-depth-guide-${offset}`}
style={{
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
left: `${offset}px`,
}}
/>
);
})}
</div>
) : null}
+14 -7
View File
@@ -496,7 +496,7 @@ test("opens a single-level thread panel with inline expansion", async ({
throw new Error("Expected root message row to have a data-message-id.");
}
const rootSummaryRow = timeline.locator(
`[data-thread-head-id="${rootMessageId}"]`,
`[data-testid="message-thread-summary"][data-thread-head-id="${rootMessageId}"]`,
);
await rootMessage.hover();
@@ -668,10 +668,13 @@ test("opens a single-level thread panel with inline expansion", async ({
await expect(nestedReplyFromBobRow).toBeVisible();
const firstReplySummaryRow = threadReplies.locator(
`[data-thread-head-id="${firstReplyId}"]`,
`[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`,
);
await expect(firstReplySummaryRow).toHaveCount(1);
await expect(firstReplySummaryRow).toContainText("2 replies");
await expect(firstReplySummaryRow).toHaveCount(0);
const firstReplyBranchRail = threadReplies.locator(
`[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`,
);
await expect(firstReplyBranchRail).toHaveCount(1);
await expect(rootSummaryRow).toContainText("18 replies");
await expect(
@@ -691,7 +694,9 @@ test("opens a single-level thread panel with inline expansion", async ({
await expectThreadReplyUnobscured(nestedReplyRow);
await firstReplySummaryRow.click();
await firstReplyBranchRail.click();
await expect(firstReplySummaryRow).toHaveCount(1);
await expect(firstReplySummaryRow).toContainText("2 replies");
await expect(
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
).toHaveCount(0);
@@ -968,7 +973,8 @@ test("ArrowUp in an empty composer edits your last message right after sending",
// Edit mode is entered for the just-sent message.
const editBanner = page.getByTestId("edit-target");
await expect(editBanner).toBeVisible();
await expect(editBanner).toContainText(message);
await expect(editBanner).toContainText("Editing message");
await expect(editBanner).not.toContainText(message);
await expect(input).toHaveText(message);
});
@@ -1034,7 +1040,8 @@ test("ArrowUp edits your last thread reply right after sending it", async ({
const editBanner = threadPanel.getByTestId("edit-target");
await expect(editBanner).toBeVisible();
await expect(editBanner).toContainText(reply);
await expect(editBanner).toContainText("Editing message");
await expect(editBanner).not.toContainText(reply);
await expect(threadInput).toHaveText(reply);
});
@@ -133,6 +133,16 @@ test.describe("thread unread indicator screenshots", () => {
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(
page
.getByTestId("message-thread-panel")
.getByTestId("thread-collapse-rail"),
).toHaveCount(0);
await expect(
page
.getByTestId("message-thread-panel")
.getByTestId("thread-collapse-guide"),
).toHaveCount(0);
await page.getByTestId("message-thread-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
@@ -360,6 +370,46 @@ test.describe("thread unread indicator screenshots", () => {
await page.screenshot({
path: `${SHOTS}/04-thread-deep-nested-unread.png`,
});
await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded();
await page
.locator(
`[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`,
)
.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(replies).toHaveCount(0);
const rootStack = page
.getByTestId("message-thread-replies")
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]`,
);
await expect(rootStack).toBeVisible();
await expect(rootStack).toContainText("6 replies");
await rootStack.click();
await expect(replies).toHaveCount(6);
await page
.locator(
`[data-testid="thread-collapse-guide"][data-thread-head-id="${r1.id}"]`,
)
.first()
.click();
await expect(replies).toHaveCount(1);
await expect(
page
.getByTestId("message-thread-replies")
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${r1.id}"]`,
),
).toBeVisible();
await expect(
page
.getByTestId("message-thread-replies")
.locator(
`[data-testid="thread-collapse-rail"][data-thread-head-id="${r1.id}"]`,
),
).toHaveCount(0);
});
test("05-thread-in-panel-subtree-badge", async ({ page }) => {