feat(desktop): autoscroll thread sidebar for new replies (#363)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-18 10:38:02 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fe93ac05a2
commit 11410be09b
4 changed files with 110 additions and 120 deletions
@@ -90,7 +90,6 @@ type ChannelPaneProps = {
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadTypingPubkeys: string[];
threadTotalReplyCount: number;
threadReplyTargetId: string | null;
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
@@ -130,7 +129,6 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessages,
threadScrollTargetId,
threadTypingPubkeys,
threadTotalReplyCount,
threadReplyTargetId,
threadReplyTargetMessage,
typingPubkeys,
@@ -286,7 +284,6 @@ export const ChannelPane = React.memo(function ChannelPane({
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
totalReplyCount={threadTotalReplyCount}
/>
) : null}
</div>
@@ -274,7 +274,7 @@ export function ChannelScreen({
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
const threadTotalReplyCount = threadPanelData.totalReplyCount;
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
@@ -466,7 +466,6 @@ export function ChannelScreen({
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
threadTotalReplyCount={threadTotalReplyCount}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
@@ -1,5 +1,5 @@
import * as React from "react";
import { X } from "lucide-react";
import { ArrowDown, X } from "lucide-react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
@@ -10,6 +10,7 @@ import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
type MessageThreadPanelProps = {
canResetWidth: boolean;
@@ -44,7 +45,6 @@ type MessageThreadPanelProps = {
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadTypingPubkeys: string[];
totalReplyCount: number;
widthPx: number;
};
@@ -98,59 +98,26 @@ export function MessageThreadPanel({
}
: null;
React.useEffect(() => {
if (!threadHeadId) {
return;
}
const threadMessages = React.useMemo(
() => threadReplies.map((entry) => entry.message),
[threadReplies],
);
const threadBody = threadBodyRef.current;
if (!threadBody) {
return;
}
const scrollToBottom = () => {
threadBody.scrollTop = threadBody.scrollHeight;
};
const frame = requestAnimationFrame(() => {
scrollToBottom();
});
const timeoutId = window.setTimeout(scrollToBottom, 300);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(timeoutId);
};
}, [threadHeadId]);
React.useEffect(() => {
if (!scrollTargetId) {
return;
}
const threadBody = threadBodyRef.current;
if (!threadBody) {
return;
}
const target = threadBody.querySelector<HTMLElement>(
`[data-message-id="${scrollTargetId}"]`,
);
if (!target) {
return;
}
const frame = requestAnimationFrame(() => {
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
onScrollTargetResolved();
return () => {
cancelAnimationFrame(frame);
};
}, [onScrollTargetResolved, scrollTargetId]);
const {
bottomAnchorRef,
contentRef,
isAtBottom,
newMessageCount,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
channelId: threadHeadId,
isLoading: false,
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
});
if (!threadHead) {
return null;
@@ -197,73 +164,95 @@ export function MessageThreadPanel({
<div
className="min-h-0 flex-1 overflow-y-auto"
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
<div ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="space-y-2">
{threadReplies.map((entry, index) => {
const nextDepth =
threadReplies[index + 1]?.message.depth ?? -1;
const isExpanded = nextDepth > entry.message.depth;
return (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary && !isExpanded ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
/>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
)}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="space-y-2">
{threadReplies.map((entry, index) => {
const nextDepth = threadReplies[index + 1]?.message.depth ?? -1;
const isExpanded = nextDepth > entry.message.depth;
return (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary && !isExpanded ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
/>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
)}
</div>
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="thread-scroll-to-latest"
onClick={() => scrollToBottom("smooth")}
size="sm"
type="button"
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
</div>
) : null}
<div className="p-4">
<TypingIndicatorRow
channel={channel}
+5
View File
@@ -151,6 +151,11 @@ dev *ARGS:
set -euo pipefail
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
TARGET=$(rustc -vV | sed -n 's|host: ||p')
mkdir -p src-tauri/binaries
touch "src-tauri/binaries/sprout-acp-$TARGET"
touch "src-tauri/binaries/sprout-mcp-server-$TARGET"
source ../scripts/instance-env.sh
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}}