desktop: thread UX, main-feed filtering, and channel header

- Anchor thread sidebar on the opened message; fetch by Nostr root; list
  messages after the focus chronologically.
- Thread composer: parent chain uses focus id before root so nested replies
  do not post as depth-1 to the channel root; keep reply target while thread
  is open.
- Main channel list: show only roots and direct replies to the thread root
  (parentId === rootId); nested replies stay out of main.
- Thread hints: aggregate by main-timeline anchor (prefer agent direct reply);
  show hints on depth-1 rows; highlight focused thread row.
- ChatHeader: optional description; channel view drops subtitle line.
- E2E: adjust integration tests for header without channel description.
- Mentions/timeline types: relay agents in mentions; displayDepth and related.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-07 16:31:53 +02:00
parent b0e0ae80a5
commit da7ded5425
12 changed files with 318 additions and 93 deletions

View File

@@ -52,7 +52,9 @@ type ChannelPaneProps = {
replyTargetId: string | null;
replyTargetMessage: TimelineMessage | null;
targetMessageId: string | null;
threadHintsByRootId: Map<string, ThreadConversationHint>;
threadHintsByAnchorId: Map<string, ThreadConversationHint>;
/** Message id to anchor the thread panel header on (the row that opened the thread). */
threadFocusEventId: string | null;
threadRootId: string | null;
typingPubkeys: string[];
};
@@ -82,7 +84,8 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetId,
replyTargetMessage,
targetMessageId,
threadHintsByRootId,
threadHintsByAnchorId,
threadFocusEventId,
threadRootId,
typingPubkeys,
}: ChannelPaneProps) {
@@ -107,7 +110,7 @@ export const ChannelPane = React.memo(function ChannelPane({
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MessageTimeline
activeThreadRootId={threadRootId}
activeThreadRootId={threadFocusEventId ?? threadRootId}
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
@@ -116,7 +119,7 @@ export const ChannelPane = React.memo(function ChannelPane({
isFetchingOlder={isFetchingOlder}
onOpenThread={onReply}
profiles={profiles}
threadHintsByRootId={threadHintsByRootId}
threadHintsByAnchorId={threadHintsByAnchorId}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
@@ -175,6 +178,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey}
disabledComposer={composerDisabled}
editTarget={editTarget}
focusEventId={threadFocusEventId}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}

View File

@@ -27,6 +27,7 @@ import {
collectMessageAuthorPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import { threadHintAnchorIdForNestedMessage } from "@/features/messages/lib/threadHintAnchor";
import {
getChannelIdFromTags,
getThreadReference,
@@ -91,6 +92,10 @@ export function ChannelScreen({
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const [threadRootId, setThreadRootId] = React.useState<string | null>(null);
/** Event id of the message row used to open the thread (Reply / thread hint). */
const [threadFocusEventId, setThreadFocusEventId] = React.useState<
string | null
>(null);
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
@@ -188,7 +193,11 @@ export function ChannelScreen({
],
);
/** Main channel timeline: top-level messages only; thread replies live in the thread sidebar. */
/**
* Main channel: top-level posts plus **direct** replies to the thread root only
* (`parentId === rootId` in NIP-10 e-tags). Nested replies (`parentId !== rootId`)
* stay sidebar-only — they must not appear in the main stream even if depth is wrong.
*/
const mainTimelineMessages = React.useMemo(
() =>
timelineMessages.filter((message) => {
@@ -196,13 +205,30 @@ export function ChannelScreen({
return true;
}
return message.depth === 0;
const { parentId, rootId } = message;
if (!parentId) {
return true;
}
if (rootId && parentId === rootId) {
return true;
}
return false;
}),
[timelineMessages],
);
/** Reply counts + participant labels for roots that have sidebar thread activity. */
const threadHintsByRootId = React.useMemo(() => {
/**
* Reply counts keyed by **main-timeline anchor** (prefer the direct reply to
* the thread root — e.g. agent message — over the user's root post).
*/
const threadHintsByAnchorId = React.useMemo(() => {
const messageById = new Map<string, TimelineMessage>(
timelineMessages.map((m) => [m.id, m]),
);
const mainIds = new Set(mainTimelineMessages.map((m) => m.id));
const aggregates = new Map<
string,
{ replyCount: number; pubkeys: string[] }
@@ -212,15 +238,17 @@ export function ChannelScreen({
if (m.kind === KIND_SYSTEM_MESSAGE) {
continue;
}
if (m.depth === 0) {
continue;
}
const rootId = m.rootId;
if (!rootId) {
const { parentId, rootId } = m;
if (!parentId || !rootId || parentId === rootId) {
continue;
}
const agg = aggregates.get(rootId) ?? { replyCount: 0, pubkeys: [] };
const anchorId = threadHintAnchorIdForNestedMessage(
m,
messageById,
mainIds,
);
const agg = aggregates.get(anchorId) ?? { replyCount: 0, pubkeys: [] };
agg.replyCount += 1;
if (m.pubkey) {
const pk = m.pubkey.toLowerCase();
@@ -228,11 +256,11 @@ export function ChannelScreen({
agg.pubkeys.push(m.pubkey);
}
}
aggregates.set(rootId, agg);
aggregates.set(anchorId, agg);
}
const out = new Map<string, ThreadConversationHint>();
for (const [rootId, agg] of aggregates) {
for (const [anchorId, agg] of aggregates) {
const participantPubkeys = agg.pubkeys.slice(0, 5);
const participantLabels = participantPubkeys.map((pk) =>
resolveUserLabel({
@@ -242,14 +270,14 @@ export function ChannelScreen({
preferResolvedSelfLabel: true,
}),
);
out.set(rootId, {
out.set(anchorId, {
replyCount: agg.replyCount,
participantPubkeys,
participantLabels,
});
}
return out;
}, [currentPubkey, messageProfiles, timelineMessages]);
}, [currentPubkey, mainTimelineMessages, messageProfiles, timelineMessages]);
const replyTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
@@ -283,6 +311,7 @@ export function ChannelScreen({
const handleReplyOpenThread = React.useCallback(
(message: TimelineMessage) => {
setThreadRootId(message.rootId ?? message.id);
setThreadFocusEventId(message.id);
handleReply(message);
},
[handleReply],
@@ -290,6 +319,7 @@ export function ChannelScreen({
const handleCloseThread = React.useCallback(() => {
setThreadRootId(null);
setThreadFocusEventId(null);
setReplyTargetId(null);
}, []);
@@ -327,7 +357,10 @@ export function ChannelScreen({
return;
}
const parentEventId = replyTargetId ?? threadRootId;
// Prefer explicit reply chain, then the message that opened the thread (e.g. agent).
// Never fall back to thread root alone when focus exists — that would create a depth-1
// reply to the root and duplicate the message in the main channel.
const parentEventId = replyTargetId ?? threadFocusEventId ?? threadRootId;
const result = await sendMessageMutation.mutateAsync({
content,
@@ -348,6 +381,7 @@ export function ChannelScreen({
queryClient,
replyTargetId,
sendMessageMutation,
threadFocusEventId,
threadRootId,
],
);
@@ -358,20 +392,6 @@ export function ChannelScreen({
[canReact, handleToggleReaction],
);
const channelDescription = activeChannel
? [
activeChannel.archivedAt ? "Archived." : null,
!activeChannel.isMember
? "Read-only until you join this open channel."
: null,
activeChannel.topic,
activeChannel.description,
activeChannel.purpose,
null,
]
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
: "Connect to the relay to browse channels and read messages.";
const shouldLoadTimeline =
activeChannel !== null && activeChannel.channelType !== "forum";
const isTimelineLoading =
@@ -384,6 +404,7 @@ export function ChannelScreen({
setReplyTargetId(null);
setEditTargetId(null);
setThreadRootId(null);
setThreadFocusEventId(null);
},
[],
);
@@ -399,13 +420,22 @@ export function ChannelScreen({
}, [activeChannelId, resetComposerTargets]);
React.useEffect(() => {
if (replyTargetId && !replyTargetMessage) {
// While the thread panel is open, keep replyTargetId even if the target is not in the
// current timeline slice momentarily — clearing it would make thread sends fall back to
// the thread root and surface replies in the main channel.
if (replyTargetId && !replyTargetMessage && !threadRootId) {
setReplyTargetId(null);
}
if (editTargetId && !editTargetMessage) {
setEditTargetId(null);
}
}, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]);
}, [
editTargetId,
editTargetMessage,
replyTargetId,
replyTargetMessage,
threadRootId,
]);
React.useEffect(() => {
resetRequestedAncestors(activeChannelId);
@@ -504,7 +534,6 @@ export function ChannelScreen({
}
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<PresenceBadge
@@ -563,7 +592,8 @@ export function ChannelScreen({
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
threadHintsByRootId={threadHintsByRootId}
threadFocusEventId={threadFocusEventId}
threadHintsByAnchorId={threadHintsByAnchorId}
threadRootId={threadRootId}
targetMessageId={
activeChannel &&

View File

@@ -6,7 +6,8 @@ import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
type ChatHeaderProps = {
actions?: React.ReactNode;
title: string;
description: string;
/** Subtitle under the title; omitted when empty. */
description?: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows";
@@ -79,12 +80,14 @@ export function ChatHeader({
</h1>
{statusBadge ? <div className="shrink-0">{statusBadge}</div> : null}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="chat-description"
>
{description}
</p>
{description?.trim() ? (
<p
className="truncate text-sm text-muted-foreground"
data-testid="chat-description"
>
{description}
</p>
) : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}

View File

@@ -284,6 +284,14 @@ export function formatTimelineMessages(
});
const thread = getThreadReference(event.tags);
const edit = editsByTargetId.get(event.id);
const depth = getDepth(event);
let displayDepth = depth;
if (thread.parentId) {
const parentEvent = eventsById.get(thread.parentId);
if (parentEvent && depth === 1 && getDepth(parentEvent) === 0) {
displayDepth = 0;
}
}
return {
id: event.id,
createdAt: event.created_at,
@@ -299,7 +307,8 @@ export function formatTimelineMessages(
body: edit ? edit.content : event.content,
parentId: thread.parentId,
rootId: thread.rootId,
depth: getDepth(event),
depth,
displayDepth,
accent: currentPubkey === authorPubkey,
pending: event.pending,
edited: edit !== undefined,

View File

@@ -0,0 +1,38 @@
import type { TimelineMessage } from "@/features/messages/types";
/**
* Where to show "N replies" for nested thread activity: walk up from the nested
* message's parent until we hit a **main-timeline** message. Prefer a **direct
* reply to the thread root** (e.g. the agent's first response) over the root
* post itself, so hints attach to the Q&A anchor users reply under.
*/
export function threadHintAnchorIdForNestedMessage(
nested: TimelineMessage,
messageById: Map<string, TimelineMessage>,
mainMessageIds: Set<string>,
): string {
let pid: string | null | undefined = nested.parentId;
let rootCandidate: string | null = null;
while (pid) {
const msg = messageById.get(pid);
if (!msg) {
break;
}
if (mainMessageIds.has(pid)) {
const isDirectReplyToThreadRoot = Boolean(
msg.parentId && msg.rootId && msg.parentId === msg.rootId,
);
if (isDirectReplyToThreadRoot) {
return pid;
}
if (!msg.parentId) {
rootCandidate = pid;
}
}
pid = msg.parentId;
}
return rootCandidate ?? nested.rootId ?? nested.id;
}

View File

@@ -1,6 +1,9 @@
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
@@ -17,6 +20,7 @@ export function useMentions(channelId: string | null) {
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgentNamesByPubkey = React.useMemo(
() =>
new Map(
@@ -27,6 +31,16 @@ export function useMentions(channelId: string | null) {
),
[managedAgentsQuery.data],
);
const relayAgentNamesByPubkey = React.useMemo(
() =>
new Map(
(relayAgentsQuery.data ?? []).map((agent) => [
agent.pubkey.toLowerCase(),
agent.name,
]),
),
[relayAgentsQuery.data],
);
const knownNames = React.useMemo<string[]>(() => {
if (!members) return [];
@@ -34,13 +48,30 @@ export function useMentions(channelId: string | null) {
for (const member of members) {
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name) {
names.push(name);
}
}
if (channelId) {
for (const agent of relayAgentsQuery.data ?? []) {
if (!agent.channelIds.includes(channelId)) {
continue;
}
if (!names.includes(agent.name)) {
names.push(agent.name);
}
}
}
return names;
}, [members, managedAgentNamesByPubkey]);
}, [
channelId,
members,
managedAgentNamesByPubkey,
relayAgentNamesByPubkey,
relayAgentsQuery.data,
]);
/** Lower-cased version of knownNames, used for case-insensitive prefix matching. */
const knownNamesLower = React.useMemo<string[]>(
@@ -80,6 +111,7 @@ export function useMentions(channelId: string | null) {
.map((member) => {
const fallbackName =
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
member.pubkey.slice(0, 8);
return {
@@ -98,7 +130,12 @@ export function useMentions(channelId: string | null) {
displayName: label,
role: member.role === "admin" ? "admin" : null,
}));
}, [managedAgentNamesByPubkey, members, mentionQuery]);
}, [
managedAgentNamesByPubkey,
members,
mentionQuery,
relayAgentNamesByPubkey,
]);
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
@@ -188,15 +225,32 @@ export function useMentions(channelId: string | null) {
}
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name && hasMention(name)) {
pubkeys.push(member.pubkey);
}
}
for (const agent of relayAgentsQuery.data ?? []) {
if (
pubkeys.some((pk) => pk.toLowerCase() === agent.pubkey.toLowerCase())
) {
continue;
}
if (hasMention(agent.name)) {
pubkeys.push(agent.pubkey);
}
}
return [...new Set(pubkeys)];
},
[members, managedAgentNamesByPubkey],
[
members,
managedAgentNamesByPubkey,
relayAgentNamesByPubkey,
relayAgentsQuery.data,
],
);
const clearMentions = React.useCallback(() => {

View File

@@ -26,6 +26,8 @@ export type TimelineMessage = {
parentId?: string | null;
rootId?: string | null;
depth: number;
/** Layout indent (0 = full-width row). First reply under a root is 0 so Q&A reads inline in main. */
displayDepth: number;
accent?: boolean;
pending?: boolean;
edited?: boolean;

View File

@@ -9,7 +9,12 @@ import {
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { channelThreadKey } from "@/features/messages/lib/messageQueryKeys";
import { getForumThread } from "@/shared/api/forum";
import type { Channel } from "@/shared/api/types";
import type {
Channel,
ForumPost,
ForumThreadResponse,
ThreadReply,
} from "@/shared/api/types";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -23,11 +28,88 @@ import { Skeleton } from "@/shared/ui/skeleton";
import { formatRelativeTime } from "@/features/forum/lib/time";
type ThreadHeaderSlice =
| { type: "post"; data: ForumPost }
| { type: "reply"; data: ThreadReply };
type ThreadTailItem =
| { type: "post"; data: ForumPost }
| { type: "reply"; data: ThreadReply };
/** Anchor header on the opened message; list messages strictly after it in time. */
function sliceThreadForFocus(
thread: ForumThreadResponse,
focusEventId: string | null,
): { header: ThreadHeaderSlice; tail: ThreadTailItem[] } {
const post = thread.post;
const entries = [
{
kind: "post" as const,
eventId: post.eventId,
createdAt: post.createdAt,
post,
},
...thread.replies.map((reply) => ({
kind: "reply" as const,
eventId: reply.eventId,
createdAt: reply.createdAt,
reply,
})),
].sort((a, b) => a.createdAt - b.createdAt);
const focusId =
focusEventId && focusEventId.length > 0 ? focusEventId : post.eventId;
const focusIdx = entries.findIndex((e) => e.eventId === focusId);
if (focusIdx < 0) {
return {
header: { type: "post", data: post },
tail: entries
.filter((e) => e.eventId !== post.eventId)
.map((e) =>
e.kind === "post"
? { type: "post" as const, data: e.post }
: { type: "reply" as const, data: e.reply },
),
};
}
const focusEntry = entries[focusIdx];
if (!focusEntry) {
return {
header: { type: "post", data: post },
tail: entries
.filter((e) => e.eventId !== post.eventId)
.map((e) =>
e.kind === "post"
? { type: "post" as const, data: e.post }
: { type: "reply" as const, data: e.reply },
),
};
}
const header: ThreadHeaderSlice =
focusEntry.kind === "post"
? { type: "post", data: focusEntry.post }
: { type: "reply", data: focusEntry.reply };
const tail = entries
.slice(focusIdx + 1)
.map((e) =>
e.kind === "post"
? { type: "post" as const, data: e.post }
: { type: "reply" as const, data: e.reply },
);
return { header, tail };
}
type ChannelThreadPanelProps = {
channel: Channel;
currentPubkey?: string;
profiles?: UserProfileLookup;
rootEventId: string;
/** Message id of the timeline row that opened this thread (header anchor). */
focusEventId: string | null;
onClose: () => void;
onCancelReply: () => void;
onSend: (
@@ -107,6 +189,7 @@ export function ChannelThreadPanel({
currentPubkey,
profiles,
rootEventId,
focusEventId,
onClose,
onCancelReply,
onSend,
@@ -134,13 +217,21 @@ export function ChannelThreadPanel({
const thread = threadQuery.data;
const isLoading = threadQuery.isPending;
const { headerSlice, tailItems } = React.useMemo(() => {
if (!thread) {
return { headerSlice: null as ThreadHeaderSlice | null, tailItems: [] };
}
const { header, tail } = sliceThreadForFocus(thread, focusEventId);
return { headerSlice: header, tailItems: tail };
}, [focusEventId, thread]);
const implicitThreadAgentMention = React.useMemo(() => {
const rootPk = thread?.post.pubkey;
if (!rootPk) {
const headerPk = headerSlice?.data.pubkey ?? thread?.post.pubkey;
if (!headerPk) {
return null;
}
const lower = rootPk.toLowerCase();
const lower = headerPk.toLowerCase();
const managed = (managedAgentsQuery.data ?? []).find(
(a) => a.pubkey.toLowerCase() === lower,
);
@@ -156,7 +247,12 @@ export function ChannelThreadPanel({
}
return null;
}, [managedAgentsQuery.data, relayAgentsQuery.data, thread?.post.pubkey]);
}, [
headerSlice,
managedAgentsQuery.data,
relayAgentsQuery.data,
thread?.post.pubkey,
]);
return (
<aside
@@ -182,18 +278,18 @@ export function ChannelThreadPanel({
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
) : thread ? (
) : thread && headerSlice ? (
<>
<div className="bg-background/40 px-3 py-3">
<div className="flex items-start gap-2">
<ProfileAvatar
avatarUrl={
profiles?.[thread.post.pubkey.toLowerCase()]?.avatarUrl ??
null
profiles?.[headerSlice.data.pubkey.toLowerCase()]
?.avatarUrl ?? null
}
className="h-8 w-8 shrink-0 rounded-lg text-[11px]"
label={resolveUserLabel({
pubkey: thread.post.pubkey,
pubkey: headerSlice.data.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
@@ -202,7 +298,7 @@ export function ChannelThreadPanel({
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{resolveUserLabel({
pubkey: thread.post.pubkey,
pubkey: headerSlice.data.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
@@ -212,9 +308,9 @@ export function ChannelThreadPanel({
<Markdown
channelNames={channelNames}
compact
content={thread.post.content}
content={headerSlice.data.content}
mentionNames={resolveMentionNames(
thread.post.tags,
headerSlice.data.tags,
profiles,
)}
/>
@@ -224,19 +320,19 @@ export function ChannelThreadPanel({
</div>
<div className="pb-2">
{thread.replies.map((reply) => (
{tailItems.map((item) => (
<ThreadReplyRow
channelNames={channelNames}
content={reply.content}
createdAt={reply.createdAt}
content={item.data.content}
createdAt={item.data.createdAt}
currentPubkey={currentPubkey}
key={reply.eventId}
key={item.data.eventId}
profiles={profiles}
pubkey={reply.pubkey}
tags={reply.tags}
pubkey={item.data.pubkey}
tags={item.data.tags}
/>
))}
{thread.replies.length === 0 ? (
{tailItems.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No replies yet. Send a message below.
</p>

View File

@@ -127,8 +127,8 @@ export const MessageRow = React.memo(
[channels],
);
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const layoutDepth = Math.min(message.displayDepth ?? message.depth, 6);
const indentPx = layoutDepth * 28;
const initials = message.author
.split(" ")
.map((part) => part[0])
@@ -220,7 +220,7 @@ export const MessageRow = React.memo(
className="relative"
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
>
{message.depth > 0 ? (
{layoutDepth > 0 ? (
<div
aria-hidden
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
@@ -366,10 +366,7 @@ export const MessageRow = React.memo(
});
}}
/>
{message.depth === 0 &&
threadHint &&
threadHint.replyCount > 0 &&
onOpenThread ? (
{threadHint && threadHint.replyCount > 0 && onOpenThread ? (
<div
className={cn(
"mt-1.5 inline-flex max-w-full flex-wrap items-center gap-2 rounded-md py-0.5 text-left text-[11px] transition-colors",
@@ -459,6 +456,7 @@ export const MessageRow = React.memo(
prev.message.accent === next.message.accent &&
prev.message.time === next.message.time &&
prev.message.depth === next.message.depth &&
prev.message.displayDepth === next.message.displayDepth &&
prev.message.kind === next.message.kind &&
prev.message.pending === next.message.pending &&
prev.message.edited === next.message.edited &&

View File

@@ -28,7 +28,7 @@ type MessageTimelineProps = {
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
profiles?: UserProfileLookup;
threadHintsByRootId?: Map<string, ThreadConversationHint>;
threadHintsByAnchorId?: Map<string, ThreadConversationHint>;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onOpenThread?: (message: TimelineMessage) => void;
@@ -55,7 +55,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
hasOlderMessages = true,
isFetchingOlder = false,
profiles,
threadHintsByRootId,
threadHintsByAnchorId,
onDelete,
onEdit,
onOpenThread,
@@ -169,7 +169,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
threadHintsByRootId={threadHintsByRootId}
threadHintsByAnchorId={threadHintsByAnchorId}
/>
) : null}

View File

@@ -20,7 +20,7 @@ type TimelineMessageListProps = {
currentPubkey?: string;
highlightedMessageId?: string | null;
messages: TimelineMessage[];
threadHintsByRootId?: Map<string, ThreadConversationHint>;
threadHintsByAnchorId?: Map<string, ThreadConversationHint>;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onOpenThread?: (message: TimelineMessage) => void;
@@ -39,7 +39,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
currentPubkey,
highlightedMessageId = null,
messages,
threadHintsByRootId,
threadHintsByAnchorId,
onDelete,
onEdit,
onOpenThread,
@@ -74,7 +74,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
/>,
);
} else {
const threadHint = threadHintsByRootId?.get(message.id);
const threadHint = threadHintsByAnchorId?.get(message.id);
elements.push(
<MessageRow
key={message.id}

View File

@@ -412,7 +412,7 @@ test("create channel with description", async ({ page }) => {
await page.goto("/");
await createStream(page, channelName, description);
await expect(page.getByTestId("chat-description")).toContainText(description);
await expect(page.getByTestId("chat-title")).toContainText(channelName);
});
test("multiple channels independent", async ({ page }) => {
@@ -503,15 +503,6 @@ test("manage sheet updates channel details and context through the relay", async
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("chat-description")).toContainText(
updatedTopic,
);
await expect(page.getByTestId("chat-description")).toContainText(
updatedDescription,
);
await expect(page.getByTestId("chat-description")).toContainText(
updatedPurpose,
);
await openChannelManagement(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(