Fix task route targets and scroll checks

This commit is contained in:
klopez4212
2026-06-30 13:55:19 +01:00
parent 75760161ba
commit 0c5ca1f565
6 changed files with 132 additions and 35 deletions
@@ -9,9 +9,14 @@ import {
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { useProfileQuery } from "@/features/profile/hooks";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import {
CHANNEL_TIMELINE_CONTENT_KINDS,
CHANNEL_TIMELINE_STATE_KINDS,
} from "@/shared/constants/kinds";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
@@ -24,6 +29,7 @@ type ChannelRouteScreenProps = {
};
const MAX_ROUTE_ANCESTOR_HOPS = 50;
const MAX_ROUTE_TASK_EVENTS = 1000;
async function fetchRouteEvent(eventId: string): Promise<RelayEvent | null> {
try {
@@ -43,8 +49,10 @@ function getReplyParentId(event: RelayEvent): string | null {
}
async function fetchRouteTargetEvents(
channelId: string,
eventIds: string[],
targetMessageId: string | null,
targetAgentConversationReplyId: string | null,
targetThreadRootId: string | null,
): Promise<RelayEvent[]> {
const eventsById = new Map<string, RelayEvent>();
@@ -73,6 +81,29 @@ async function fetchRouteTargetEvents(
addEvent(await fetchRouteEvent(threadRootId));
}
if (targetAgentConversationReplyId && threadRootId) {
try {
const taskEvents = await relayClient.fetchEvents({
"#e": [threadRootId],
"#h": [channelId],
kinds: [
...CHANNEL_TIMELINE_CONTENT_KINDS,
...CHANNEL_TIMELINE_STATE_KINDS,
],
limit: MAX_ROUTE_TASK_EVENTS,
});
for (const event of taskEvents) {
addEvent(event);
}
} catch (error) {
console.error(
"Failed to load route task conversation",
targetAgentConversationReplyId,
error,
);
}
}
let parentId = getReplyParentId(targetEvent);
let guard = 0;
while (
@@ -171,8 +202,10 @@ export function ChannelRouteScreen({
].filter((eventId): eventId is string => eventId !== null);
void fetchRouteTargetEvents(
channelId,
eventIds,
targetAgentConversationReplyId ?? targetMessageId,
targetAgentConversationReplyId,
targetThreadRootId,
).then((events) => {
if (!isCancelled) {
@@ -191,6 +224,7 @@ export function ChannelRouteScreen({
};
}, [
selectedPostId,
channelId,
targetAgentConversationReplyId,
targetMessageId,
targetThreadRootId,
@@ -397,7 +397,6 @@ export const ChannelPane = React.memo(function ChannelPane({
if (isThreadedTask) {
onOpenThread(threadMessage);
onSelectThreadReplyTarget(message);
onThreadScrollTargetChange(message.id);
return;
}
@@ -408,7 +407,6 @@ export const ChannelPane = React.memo(function ChannelPane({
[
onCloseThread,
onOpenThread,
onSelectThreadReplyTarget,
onSurfaceTabChange,
onThreadScrollTargetChange,
],
@@ -728,9 +728,10 @@ export function ChannelScreen({
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
}, [clearMessageRouteTarget]);
const [
pendingMainTimelineRouteTargetId,
setPendingMainTimelineRouteTargetId,
] = React.useState<string | null>(null);
React.useEffect(() => {
resetComposerTargets(activeChannelId);
}, [activeChannelId, resetComposerTargets]);
@@ -768,19 +769,55 @@ export function ChannelScreen({
targetAgentConversationReplyId,
timelineMessages,
});
const mainTimelineTargetMessageId = useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession: handleCloseAgentSession,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadScrollTargetId,
targetMessageId,
timelineMessages,
});
const { mainTimelineTargetMessageId, rootThreadHeadTargetId } =
useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession: handleCloseAgentSession,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadScrollTargetId,
targetMessageId,
timelineMessages,
});
const handleTargetReached = React.useCallback(
(messageId: string) => {
setPendingMainTimelineRouteTargetId((current) =>
current === messageId ? null : current,
);
if (rootThreadHeadTargetId === messageId) {
handleCloseAgentSession();
setProfilePanelPubkey(null, { replace: true });
setEditTargetId(null);
setOpenThreadHeadId(messageId, { replace: true });
setThreadReplyTargetId(messageId);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
}
clearMessageRouteTarget({ replace: true });
},
[
clearMessageRouteTarget,
handleCloseAgentSession,
rootThreadHeadTargetId,
setOpenThreadHeadId,
setProfilePanelPubkey,
],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: activeChannelId is the reset trigger; the effect intentionally clears target state when the channel changes.
React.useEffect(() => {
setPendingMainTimelineRouteTargetId(null);
}, [activeChannelId]);
React.useEffect(() => {
if (mainTimelineTargetMessageId) {
setPendingMainTimelineRouteTargetId(mainTimelineTargetMessageId);
}
}, [mainTimelineTargetMessageId]);
const effectiveMainTimelineTargetMessageId =
pendingMainTimelineRouteTargetId ?? mainTimelineTargetMessageId;
React.useEffect(() => {
if (openThreadHeadId && !openThreadHeadMessage) {
// While the timeline is still loading (e.g. a reload restoring the
@@ -1063,7 +1100,7 @@ export function ChannelScreen({
onSurfaceTabChange={handleSurfaceTabChange}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
targetMessageId={mainTimelineTargetMessageId}
targetMessageId={effectiveMainTimelineTargetMessageId}
threadHeadMessage={displayedThreadHeadMessage}
threadMessages={displayedThreadMessages}
threadPanelWidthPx={threadPanelWidthPx}
@@ -45,7 +45,11 @@ function getRouteMainTimelineTargetId(
return null;
}
if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
if (!targetMessage) {
return null;
}
if (!targetMessage.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
return targetMessageId;
}
@@ -88,6 +92,10 @@ export function useChannelRouteTarget({
targetMessageId,
targetTimelineMessage,
);
const rootThreadHeadTargetId =
targetTimelineMessage && !targetTimelineMessage.parentId
? targetTimelineMessage.id
: null;
const handledThreadRouteTargetRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -115,18 +123,9 @@ export function useChannelRouteTarget({
}
if (!targetMessage.parentId) {
closeAgentSession();
// Root message links should open the reply panel for that root. The
// timeline scroll/highlight target alone is not enough: root links have
// no parent/thread metadata, so the reply-only branch below cannot infer
// a thread head.
setProfilePanelPubkey(null, { replace: true });
setEditTargetId(null);
setOpenThreadHeadId(targetMessage.id, { replace: true });
setThreadReplyTargetId(targetMessage.id);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
handledThreadRouteTargetRef.current = targetKey;
// Root links still need to open the reply panel, but not until the main
// timeline has centered the row. Opening the panel first changes the main
// column layout mid-jump and can make the virtualized target abandon early.
return;
}
@@ -166,5 +165,5 @@ export function useChannelRouteTarget({
timelineMessageById,
]);
return mainTimelineTargetMessageId;
return { mainTimelineTargetMessageId, rootThreadHeadTargetId };
}
+7 -1
View File
@@ -6377,10 +6377,16 @@ async function handleGetEvent(
},
config: E2eConfig | undefined,
) {
const mockEvent = Array.from(mockMessages.values())
.flat()
.find((item) => item.id === args.eventId);
if (mockEvent) {
return JSON.stringify(mockEvent);
}
const identity = getIdentity(config);
if (!identity) {
const knownEvents: RelayEvent[] = [
...Array.from(mockMessages.values()).flat(),
{
id: "mock-engineering-shipped",
pubkey:
+24 -1
View File
@@ -167,7 +167,30 @@ async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
}
}
throw new Error("Failed to scroll the timeline away from the bottom.");
await timeline.evaluate((element, distance) => {
const timeline = element as HTMLDivElement;
timeline.dispatchEvent(
new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaY: -800,
}),
);
const maxScrollTop = Math.max(
0,
timeline.scrollHeight - timeline.clientHeight,
);
timeline.scrollTop = Math.max(0, maxScrollTop - distance - 80);
timeline.dispatchEvent(new Event("scroll", { bubbles: true }));
}, minDistance);
await expect
.poll(async () => {
const metrics = await getTimelineMetrics(page);
const scrollToLatestVisible = await isScrollToLatestVisible(page);
return metrics.distanceFromBottom > minDistance && scrollToLatestVisible;
})
.toBe(true);
}
test.beforeAll(async () => {