mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 15:50:35 +00:00
feat(frontend): pin the sent message to the top of the viewport (#8261)
* feat(frontend): pin the sent message to the top of the viewport After a send, keep the user's turn at the top of the viewport and let the streamed reply grow downward below the reading position, instead of the viewport chasing the bottom (ChatGPT-style send behavior). MessageList detects a freshly appended trailing human group while a run is active and hands its key to VirtualMessageList. The list then grows a flexible end spacer sized to the remaining viewport, so the last row can actually reach the top (the browser clamps the scroll without it), escapes the stick-to-bottom lock around the alignment, and re-escapes on a timeout chain until the resize animation from the optimistic message settles; the spacer shrinks as the reply grows and collapses when the run finishes. Scrolling down or the scroll-to-bottom button re-locks the follow. History pagination, thread switches and regenerates are excluded by construction and covered by DOM tests; Playwright covers the static and the virtualized list against a held run stream. * style(frontend): fix scroll-on-send test lint * fix(frontend): send-pin review follow-ups Review follow-ups on top of the send pin: - Hand scroll control back on the first genuine user input: the pin chain arms wheel/touchstart/pointerdown listeners on the scroller, and outline navigation plus the scroll-to-bottom button cancel it through cancelPinFollow — previously every tick reverted user scrolling for ~2s after a send. - Capture the mount-time tail key during the first render so the mount absorption is idempotent under StrictMode's double effect run. - Export LOCAL_HUMAN_MESSAGE_ID_PREFIX from core/threads/hooks and use it at both the mint site and the pin predicate. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
9134cf26c6
commit
ec44f2ff3d
@ -105,6 +105,7 @@ import {
|
||||
findSubtaskResultMessage,
|
||||
} from "@/core/tasks/subtask-render";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { LOCAL_HUMAN_MESSAGE_ID_PREFIX } from "@/core/threads/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
@ -563,6 +564,64 @@ export function MessageList({
|
||||
prevIsLoading.current = thread.isLoading;
|
||||
}, [groupedMessages, thread.error, thread.isLoading, threadId]);
|
||||
|
||||
// Pin a freshly sent user turn to the top of the viewport, ChatGPT-style:
|
||||
// the reply then grows downward below the reading position instead of the
|
||||
// viewport chasing the bottom. The list escapes the stick lock and grows a
|
||||
// flexible end spacer so the last row can actually reach the top (see
|
||||
// `pinGroupKey` in VirtualMessageList); scrolling down or the
|
||||
// scroll-to-bottom button re-locks the follow.
|
||||
//
|
||||
// "Freshly sent" is identified by the optimistic message identity
|
||||
// (LOCAL_HUMAN_MESSAGE_ID_PREFIX, minted once per turn in the thread hooks
|
||||
// and shared with the server echo), not by comparing group tails: the
|
||||
// optimistic append can render before `isLoading` flips, and a regenerate
|
||||
// masks the superseded assistant messages first, which exposes an existing
|
||||
// human turn as the last group mid-run. Identity also excludes history
|
||||
// loads (prepend), thread switches and restoring a mid-run thread.
|
||||
const [pinGroupKey, setPinGroupKey] = useState<string | null>(null);
|
||||
const lastSeenThreadIdRef = useRef(threadId);
|
||||
const pinnedKeysRef = useRef<Set<string>>(new Set());
|
||||
// Captured during the first render, not inside the effect: React StrictMode
|
||||
// (the dev default) runs effects mount → cleanup → mount with refs
|
||||
// preserved, so a run-once flag mutated in the effect would let the second
|
||||
// pass fall through and pin a restored mid-run thread. The mount-time tail
|
||||
// absorbs idempotently on every pass.
|
||||
const mountTailKeyRef = useRef<string | null | undefined>(undefined);
|
||||
if (mountTailKeyRef.current === undefined) {
|
||||
const firstGroup = groupedMessages.at(-1);
|
||||
mountTailKeyRef.current =
|
||||
firstGroup?.id ?? firstGroup?.messages[0]?.id ?? null;
|
||||
}
|
||||
useEffect(() => {
|
||||
const lastGroup = groupedMessages.at(-1);
|
||||
const lastGroupKey = lastGroup?.id ?? lastGroup?.messages[0]?.id;
|
||||
if (lastGroupKey != null && lastGroupKey === mountTailKeyRef.current) {
|
||||
// The tail that already existed at mount (restoring a mid-run thread)
|
||||
// is never a fresh send.
|
||||
pinnedKeysRef.current.add(lastGroupKey);
|
||||
}
|
||||
if (lastSeenThreadIdRef.current !== threadId) {
|
||||
lastSeenThreadIdRef.current = threadId;
|
||||
setPinGroupKey(null);
|
||||
if (lastGroupKey) pinnedKeysRef.current.add(lastGroupKey);
|
||||
return;
|
||||
}
|
||||
if (!thread.isLoading) {
|
||||
// The run finished (or never started): drop the pin so the end spacer
|
||||
// collapses with it.
|
||||
setPinGroupKey(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lastGroup?.type === "human" &&
|
||||
lastGroupKey?.startsWith(LOCAL_HUMAN_MESSAGE_ID_PREFIX) &&
|
||||
!pinnedKeysRef.current.has(lastGroupKey)
|
||||
) {
|
||||
pinnedKeysRef.current.add(lastGroupKey);
|
||||
setPinGroupKey(lastGroupKey);
|
||||
}
|
||||
}, [groupedMessages, thread.isLoading, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = turnStartTimeRef.current;
|
||||
if (
|
||||
@ -1309,6 +1368,7 @@ export function MessageList({
|
||||
ref={virtualMessageListRef}
|
||||
groups={groupedMessages}
|
||||
isLoading={thread.isLoading}
|
||||
pinGroupKey={pinGroupKey}
|
||||
onActiveGroupChange={
|
||||
conversationOutlineEnabled ? handleActiveGroupChange : undefined
|
||||
}
|
||||
@ -1813,6 +1873,10 @@ export function MessageList({
|
||||
<ConversationScrollButton
|
||||
aria-label={t.common.scrollToBottom}
|
||||
className="z-10"
|
||||
// The jump button is explicit user intent that originates outside
|
||||
// the scroll element, so its input events cannot cancel a running
|
||||
// send-pin chain; do it here.
|
||||
onPointerDown={() => virtualMessageListRef.current?.cancelPinFollow()}
|
||||
/>
|
||||
</Conversation>
|
||||
{conversationOutlineEnabled && (
|
||||
|
||||
@ -11,7 +11,9 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Key,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@ -24,6 +26,7 @@ const ESTIMATED_ROW_HEIGHT = 176;
|
||||
const GROUP_START_OFFSET = 16;
|
||||
const VIRTUAL_SCROLL_SETTLE_ATTEMPTS = 4;
|
||||
const STATIC_SCROLL_SETTLE_ATTEMPTS = 2;
|
||||
const PIN_SETTLE_FRAMES = 5;
|
||||
|
||||
type GroupAlignment = "start" | "center";
|
||||
|
||||
@ -42,10 +45,27 @@ type VirtualMessageListProps = {
|
||||
*/
|
||||
renderAfterGroup?: (index: number) => ReactNode;
|
||||
onActiveGroupChange?: (groupIndex: number) => void;
|
||||
/**
|
||||
* Key of a freshly sent user group to pin to the top of the viewport
|
||||
* (ChatGPT-style send behavior). Keyed, not indexed: history pagination
|
||||
* prepends groups and would shift an index. While set, a flexible spacer
|
||||
* grows at the end of the list so the pinned row can actually reach the
|
||||
* top — it is the last row, so without the spacer the browser clamps the
|
||||
* scroll — and the spacer shrinks as the streamed reply grows beneath it.
|
||||
* `null` clears it.
|
||||
*/
|
||||
pinGroupKey?: string | null;
|
||||
};
|
||||
|
||||
export type VirtualMessageListHandle = {
|
||||
scrollToGroup: (groupIndex: number, options?: ScrollToGroupOptions) => void;
|
||||
/**
|
||||
* Hands scroll control back to the user immediately: cancels a running
|
||||
* send-pin chain (escape ticks and settle frames) without clearing the
|
||||
* pin itself. Wired to explicit user intents that do not originate as
|
||||
* input on the scroll element, e.g. the scroll-to-bottom button.
|
||||
*/
|
||||
cancelPinFollow: () => void;
|
||||
};
|
||||
|
||||
function groupKey(group: MessageGroup | undefined, index: number): Key {
|
||||
@ -60,12 +80,21 @@ export const VirtualMessageList = forwardRef<
|
||||
VirtualMessageListHandle,
|
||||
VirtualMessageListProps
|
||||
>(function VirtualMessageList(
|
||||
{ groups, isLoading, renderGroup, renderAfterGroup, onActiveGroupChange },
|
||||
{
|
||||
groups,
|
||||
isLoading,
|
||||
renderGroup,
|
||||
renderAfterGroup,
|
||||
onActiveGroupChange,
|
||||
pinGroupKey,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { isAtBottom, scrollRef, scrollToBottom, stopScroll } =
|
||||
useStickToBottomContext();
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pinSpacerHeight, setPinSpacerHeight] = useState(0);
|
||||
const pinSpacerHeightRef = useRef(0);
|
||||
const activeIndex = isLoading ? groups.length - 1 : -1;
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => groupKey(groups[index], index),
|
||||
@ -131,6 +160,199 @@ export const VirtualMessageList = forwardRef<
|
||||
[scrollRef],
|
||||
);
|
||||
|
||||
const pinnedIndex = useMemo(() => {
|
||||
if (pinGroupKey == null) {
|
||||
return null;
|
||||
}
|
||||
const index = groups.findIndex(
|
||||
(_, groupIndex) => String(getItemKey(groupIndex)) === pinGroupKey,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
}, [getItemKey, groups, pinGroupKey]);
|
||||
|
||||
const updatePinSpacer = useCallback(() => {
|
||||
const viewport = scrollRef.current;
|
||||
if (pinnedIndex == null || !viewport) {
|
||||
return;
|
||||
}
|
||||
const row = listRef.current?.querySelector<HTMLElement>(
|
||||
`[data-message-group-index="${pinnedIndex}"]`,
|
||||
);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
// Everything after the pinned row, excluding this spacer itself and the
|
||||
// row's own height: the spacer must make up whatever is missing so the
|
||||
// row can still reach the top of the viewport.
|
||||
const afterContent = Math.max(
|
||||
0,
|
||||
viewport.scrollHeight -
|
||||
pinSpacerHeightRef.current -
|
||||
(rowRect.bottom + viewport.scrollTop),
|
||||
);
|
||||
const needed = Math.max(
|
||||
0,
|
||||
viewport.clientHeight -
|
||||
rowRect.height -
|
||||
GROUP_START_OFFSET -
|
||||
afterContent,
|
||||
);
|
||||
pinSpacerHeightRef.current = needed;
|
||||
setPinSpacerHeight((previous) => (previous === needed ? previous : needed));
|
||||
}, [pinnedIndex, scrollRef]);
|
||||
|
||||
// Runs the pin chain once per pin key: frame one sizes the spacer, frame
|
||||
// two aligns, then a few settle frames re-align (the virtualized branch
|
||||
// re-measures rows after the alignment). The whole chain — rAF ids and
|
||||
// escape timeouts — lives in a ref and is NOT cancelled by this effect's
|
||||
// cleanup: groups churn on every stream chunk, and a chunk landing between
|
||||
// the frames would otherwise cancel the pending alignment mid-chain and
|
||||
// restart it against already-grown geometry (the spacer computes to 0 and
|
||||
// the pin collapses). Only clearing the pin or unmount cancels.
|
||||
//
|
||||
// The spacer is sized once and stays frozen for the run: re-measuring per
|
||||
// chunk would shrink it in lockstep with the reply, and the net-negative
|
||||
// resize makes use-stick-to-bottom re-lock its follow while the bottom gap
|
||||
// is still inside its 70px band — after which every chunk drags the
|
||||
// viewport down without user input. A frozen spacer keeps each chunk a
|
||||
// net-positive resize (the follow stays escaped), and an explicit user
|
||||
// scroll down re-locks normally through scroll events. The spacer
|
||||
// collapses when the pin clears.
|
||||
const pinChainKeyRef = useRef<string | null>(null);
|
||||
const pinFramesRef = useRef<number[]>([]);
|
||||
const pinEscapeTimersRef = useRef<number[]>([]);
|
||||
// User-intent listeners, attached for the chain's lifetime: `wheel`,
|
||||
// `touchstart` and `pointerdown` never fire for programmatic `scrollTo`, so
|
||||
// they distinguish real scrolling (wheel/trackpad/touch, scrollbar and
|
||||
// selection drags) from the resize-animation churn the chain defends
|
||||
// against. The first genuine input hands scroll control back to the user.
|
||||
const pinUserIntentBoundRef = useRef(false);
|
||||
const cancelPinChain = useCallback(() => {
|
||||
for (const frame of pinFramesRef.current) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
pinFramesRef.current = [];
|
||||
for (const timer of pinEscapeTimersRef.current) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
pinEscapeTimersRef.current = [];
|
||||
if (pinUserIntentBoundRef.current) {
|
||||
pinUserIntentBoundRef.current = false;
|
||||
const viewport = scrollRef.current;
|
||||
if (viewport) {
|
||||
viewport.removeEventListener("wheel", cancelPinChain);
|
||||
viewport.removeEventListener("touchstart", cancelPinChain);
|
||||
viewport.removeEventListener("pointerdown", cancelPinChain);
|
||||
}
|
||||
}
|
||||
}, [scrollRef]);
|
||||
const armPinUserIntent = useCallback(() => {
|
||||
if (pinUserIntentBoundRef.current) {
|
||||
return;
|
||||
}
|
||||
const viewport = scrollRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
pinUserIntentBoundRef.current = true;
|
||||
viewport.addEventListener("wheel", cancelPinChain, { passive: true });
|
||||
viewport.addEventListener("touchstart", cancelPinChain, {
|
||||
passive: true,
|
||||
});
|
||||
viewport.addEventListener("pointerdown", cancelPinChain, {
|
||||
passive: true,
|
||||
});
|
||||
}, [cancelPinChain, scrollRef]);
|
||||
useLayoutEffect(() => {
|
||||
if (pinnedIndex == null) {
|
||||
pinChainKeyRef.current = null;
|
||||
cancelPinChain();
|
||||
if (pinSpacerHeightRef.current !== 0) {
|
||||
pinSpacerHeightRef.current = 0;
|
||||
setPinSpacerHeight(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pinChainKeyRef.current === pinGroupKey) {
|
||||
// The chain already ran (or is running) for this pin; groups churn
|
||||
// every stream chunk and must not restart it.
|
||||
return;
|
||||
}
|
||||
pinChainKeyRef.current = pinGroupKey ?? null;
|
||||
// A second send within the first chain's 2s window starts a new chain;
|
||||
// without this cancel the old chain's pending ticks keep aligning the
|
||||
// previous pin (and its timer array is overwritten, orphaning them so
|
||||
// they can never be cancelled).
|
||||
cancelPinChain();
|
||||
armPinUserIntent();
|
||||
const trackFrame = (callback: FrameRequestCallback) => {
|
||||
const frame = requestAnimationFrame((time) => {
|
||||
pinFramesRef.current = pinFramesRef.current.filter(
|
||||
(pending) => pending !== frame,
|
||||
);
|
||||
callback(time);
|
||||
});
|
||||
pinFramesRef.current.push(frame);
|
||||
return frame;
|
||||
};
|
||||
trackFrame(() => {
|
||||
updatePinSpacer();
|
||||
trackFrame(() => {
|
||||
// Escaping before the alignment is not redundant even though pinning
|
||||
// scrolls down: locking back in on a down-scroll is the library's
|
||||
// default.
|
||||
stopScroll();
|
||||
alignGroupToViewport(pinnedIndex, "start", "auto");
|
||||
// The alignment scrolls down into the spacer region while a
|
||||
// stick-to-bottom resize animation (from the optimistic message
|
||||
// growth) is still writing scroll offsets toward the spacer bottom.
|
||||
// Every write's scroll event re-locks the follow, and a locked follow
|
||||
// later chases the real bottom once the reply grows. The animation
|
||||
// converges to the spacer bottom over ~350ms — visually close to the
|
||||
// pinned position but not exact. So each escape tick also re-aligns:
|
||||
// the chain spans the animation's duration plus its end-of-animation
|
||||
// requeue, and the LAST tick runs after the animation has died, so
|
||||
// the exact pinned offset wins. The chain spans two seconds: a send
|
||||
// that lands while the restored thread's initial virtual positioning
|
||||
// and row measurement are still settling interleaves with that churn,
|
||||
// which outlasts the resize animation itself. After the chain, the
|
||||
// user's own down-scroll re-locks normally.
|
||||
pinEscapeTimersRef.current = [
|
||||
0, 100, 200, 350, 500, 700, 900, 1100, 1400, 1700, 2000,
|
||||
].map((delay) =>
|
||||
window.setTimeout(() => {
|
||||
stopScroll();
|
||||
alignGroupToViewport(pinnedIndex, "start", "auto");
|
||||
}, delay),
|
||||
);
|
||||
// Settle frames re-align against post-alignment measurement shifts.
|
||||
let pending = PIN_SETTLE_FRAMES;
|
||||
const scheduleSettle = () => {
|
||||
if (pending <= 0) {
|
||||
return;
|
||||
}
|
||||
pending -= 1;
|
||||
trackFrame(() => {
|
||||
alignGroupToViewport(pinnedIndex, "start", "auto");
|
||||
scheduleSettle();
|
||||
});
|
||||
};
|
||||
scheduleSettle();
|
||||
});
|
||||
});
|
||||
}, [
|
||||
alignGroupToViewport,
|
||||
armPinUserIntent,
|
||||
cancelPinChain,
|
||||
groups,
|
||||
pinGroupKey,
|
||||
pinnedIndex,
|
||||
stopScroll,
|
||||
updatePinSpacer,
|
||||
]);
|
||||
useEffect(() => cancelPinChain, [cancelPinChain]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@ -138,6 +360,9 @@ export const VirtualMessageList = forwardRef<
|
||||
if (groupIndex < 0 || groupIndex >= groups.length) {
|
||||
return;
|
||||
}
|
||||
// Outline chapter navigation is explicit user intent: a running
|
||||
// send-pin chain must not revert it on its next tick.
|
||||
cancelPinChain();
|
||||
stopScroll();
|
||||
const behavior = options?.behavior ?? "auto";
|
||||
const align = options?.align ?? "start";
|
||||
@ -164,9 +389,13 @@ export const VirtualMessageList = forwardRef<
|
||||
};
|
||||
requestAnimationFrame(settleOnExactGroup);
|
||||
},
|
||||
cancelPinFollow() {
|
||||
cancelPinChain();
|
||||
},
|
||||
}),
|
||||
[
|
||||
alignGroupToViewport,
|
||||
cancelPinChain,
|
||||
groups.length,
|
||||
shouldVirtualize,
|
||||
stopScroll,
|
||||
@ -310,6 +539,9 @@ export const VirtualMessageList = forwardRef<
|
||||
{renderAfterGroup?.(index)}
|
||||
</div>
|
||||
))}
|
||||
{pinSpacerHeight > 0 && (
|
||||
<div aria-hidden style={{ height: pinSpacerHeight }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -337,6 +569,16 @@ export const VirtualMessageList = forwardRef<
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{pinSpacerHeight > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: pinSpacerHeight,
|
||||
transform: `translateY(${virtualizer.getTotalSize()}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -510,6 +510,15 @@ function clearReconnectRun(threadId: string, runId: string): void {
|
||||
// Storage access is best-effort and must never block stream cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix of the client-generated id minted once per turn for the visible
|
||||
* human message (`${LOCAL_HUMAN_MESSAGE_ID_PREFIX}${uuid()}`); the optimistic
|
||||
* display copy and the submitted message share it, and the server echo
|
||||
* (`<id>__user`) keeps it. Consumers that must recognize an optimistic human
|
||||
* (e.g. the send-scroll pin) match on this prefix instead of restating it.
|
||||
*/
|
||||
export const LOCAL_HUMAN_MESSAGE_ID_PREFIX = "local-human-";
|
||||
/**
|
||||
* The turn this client submitted, recorded at dispatch time. The visible human
|
||||
* input gets one client-generated identity shared by the optimistic display
|
||||
@ -3071,7 +3080,7 @@ export function useThreadStream({
|
||||
// checkpoint must never promote an older history-only human into this
|
||||
// turn's anchor).
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const humanMessageId = `local-human-${uuid()}`;
|
||||
const humanMessageId = `${LOCAL_HUMAN_MESSAGE_ID_PREFIX}${uuid()}`;
|
||||
localTurnAnchorRef.current = {
|
||||
threadId,
|
||||
humanIdentity: hideFromUI ? null : `message:${humanMessageId}`,
|
||||
|
||||
326
frontend/tests/e2e/scroll-on-send.spec.ts
Normal file
326
frontend/tests/e2e/scroll-on-send.spec.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
const SENT_TEXT = "Pin this message to the top of the viewport.";
|
||||
const TALL_ANSWER = Array.from(
|
||||
{ length: 24 },
|
||||
(_, index) =>
|
||||
`Paragraph ${index}: the streamed reply keeps growing line after line, ` +
|
||||
"and the viewport must stay at the reader's position instead of " +
|
||||
"chasing the bottom of the conversation.",
|
||||
).join("\n\n");
|
||||
|
||||
function fillerMessages(turns: number) {
|
||||
return Array.from({ length: turns }, (_, index) => [
|
||||
{
|
||||
type: "human",
|
||||
id: `msg-human-filler-${index}`,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Filler question ${index}: describe the message list scroll behavior in detail.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: `msg-ai-filler-${index}`,
|
||||
content: `Filler answer ${index}: the message list keeps the latest turn in view, escapes the stick lock when the user scrolls up, and exposes a jump control to return.`,
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
]).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE server that answers the run handshake, then — once the send has been
|
||||
* pinned — delivers the reply and holds the connection open. The held
|
||||
* connection keeps `thread.isLoading` true so the pin and its end spacer
|
||||
* stay alive for the assertions. `progressive` delivers the reply one
|
||||
* paragraph per 200ms tick (real streaming shape); otherwise the whole tall
|
||||
* answer arrives in one `values` frame.
|
||||
*/
|
||||
async function startHeldRunStream(options?: { progressive?: boolean }) {
|
||||
const progressive = options?.progressive ?? false;
|
||||
const paragraphs = TALL_ANSWER.split("\n\n");
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
let body = "";
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on("end", () => {
|
||||
let inputMessages: unknown[] = [];
|
||||
try {
|
||||
inputMessages =
|
||||
(JSON.parse(body) as { input?: { messages?: unknown[] } }).input
|
||||
?.messages ?? [];
|
||||
} catch {
|
||||
inputMessages = [];
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
});
|
||||
const write = (event: string, data: unknown) => {
|
||||
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const answerFrame = (content: string) => ({
|
||||
messages: [
|
||||
...inputMessages,
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-scroll-send",
|
||||
content,
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
write("metadata", {
|
||||
run_id: "run-scroll-send",
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
});
|
||||
if (progressive) {
|
||||
paragraphs.forEach((paragraph, index) => {
|
||||
timers.push(
|
||||
setTimeout(
|
||||
() =>
|
||||
write(
|
||||
"values",
|
||||
answerFrame(paragraphs.slice(0, index + 1).join("\n\n")),
|
||||
),
|
||||
600 + index * 200,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
timers.push(
|
||||
setTimeout(() => write("values", answerFrame(TALL_ANSWER)), 600),
|
||||
);
|
||||
}
|
||||
// Connection stays open: the run remains in flight.
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/runs/stream`,
|
||||
async close() {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Scroll on send", () => {
|
||||
// ChatGPT-style send behavior: the freshly sent user turn aligns with the
|
||||
// top of the viewport, and the streamed reply grows downward below the
|
||||
// reading position instead of the viewport chasing the bottom. A flexible
|
||||
// end spacer makes the last row able to reach the top at all, and the
|
||||
// scroll-to-bottom button re-locks the follow when the user wants it back.
|
||||
async function expectSendPinned(
|
||||
page: Page,
|
||||
fillerTurns: number,
|
||||
options?: { progressive?: boolean },
|
||||
) {
|
||||
const streamServer = await startHeldRunStream(options);
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Scroll on send",
|
||||
updated_at: "2026-11-14T12:00:00Z",
|
||||
messages: fillerMessages(fillerTurns),
|
||||
},
|
||||
],
|
||||
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
const lastFiller = page
|
||||
.getByText(`Filler question ${fillerTurns - 1}`, { exact: false })
|
||||
.first();
|
||||
await expect(lastFiller).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const scroller = page.getByRole("log").locator("> div");
|
||||
// The restored thread settles pinned to the bottom.
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
await textarea.fill(SENT_TEXT);
|
||||
await textarea.press("Enter");
|
||||
|
||||
const sentMessage = page.getByText(SENT_TEXT, { exact: true });
|
||||
await expect(sentMessage).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The user's message now sits at the top of the viewport (the group
|
||||
// start offset is 16px), no longer hugging the bottom edge.
|
||||
const scrollerHandle = await scroller.elementHandle();
|
||||
const sentTopInViewport = () =>
|
||||
sentMessage.evaluate(
|
||||
(el, scrollerEl) =>
|
||||
el.getBoundingClientRect().top -
|
||||
scrollerEl.getBoundingClientRect().top,
|
||||
scrollerHandle,
|
||||
);
|
||||
await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64);
|
||||
|
||||
// The tall streamed reply lands below the pinned message. Escape means
|
||||
// the viewport does NOT chase it: the distance to the bottom grows.
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(200);
|
||||
|
||||
// A slow bottom-chasing animation (a resurrected follow lock) would
|
||||
// drag the message up past the viewport top within this window; the
|
||||
// pin must stay put after the content has settled.
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64);
|
||||
const settledGap = await scroller.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
|
||||
);
|
||||
expect(settledGap).toBeGreaterThan(70);
|
||||
|
||||
// And the follow can be re-locked on demand: the jump button appears
|
||||
// because the list escaped the stick lock.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Scroll to bottom" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await streamServer.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("pins the sent user message to the top (static list)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expectSendPinned(page, 12);
|
||||
});
|
||||
|
||||
test("pins the sent user message to the top (virtualized list)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// 32 turns = 64 groups, past the list's virtualization threshold (60).
|
||||
await expectSendPinned(page, 32);
|
||||
});
|
||||
|
||||
// Regression: a progressively streamed reply (one paragraph per 200ms) must
|
||||
// not re-lock the follow. If the end spacer shrank in lockstep with each
|
||||
// chunk, the net-negative resize re-locks use-stick-to-bottom while the
|
||||
// bottom gap is inside its 70px band, and every subsequent chunk drags the
|
||||
// viewport down without user input.
|
||||
test("keeps the pin while the reply streams in progressively", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expectSendPinned(page, 12, { progressive: true });
|
||||
});
|
||||
|
||||
// The send-pin chain (escape + re-align ticks for ~2s after a send) must
|
||||
// hand scroll control back on the first genuine user input. A wheel over
|
||||
// the list inside the chain window cancels it; without the cancel, a later
|
||||
// tick would revert the user's position back to the pinned offset.
|
||||
test("hands scroll control back when the user wheels inside the chain window", async ({
|
||||
page,
|
||||
}) => {
|
||||
const streamServer = await startHeldRunStream();
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Scroll on send",
|
||||
updated_at: "2026-11-14T12:00:00Z",
|
||||
messages: fillerMessages(12),
|
||||
},
|
||||
],
|
||||
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(
|
||||
page.getByText("Filler question 11", { exact: false }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const scroller = page.getByRole("log").locator("> div");
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
await textarea.fill(SENT_TEXT);
|
||||
await textarea.press("Enter");
|
||||
|
||||
const sentMessage = page.getByText(SENT_TEXT, { exact: true });
|
||||
await expect(sentMessage).toBeVisible({ timeout: 15_000 });
|
||||
const scrollerHandle = await scroller.elementHandle();
|
||||
const sentTopInViewport = () =>
|
||||
sentMessage.evaluate(
|
||||
(el, scrollerEl) =>
|
||||
el.getBoundingClientRect().top -
|
||||
scrollerEl.getBoundingClientRect().top,
|
||||
scrollerHandle,
|
||||
);
|
||||
// The pin landed; still well inside the 2s chain window.
|
||||
await expect.poll(sentTopInViewport).toBeLessThanOrEqual(64);
|
||||
|
||||
const box = await scroller.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("scroll container is not visible");
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, -240);
|
||||
|
||||
// The wheel-up moves the viewport up, pushing the pinned message down
|
||||
// by the same amount (~240px past its pinned offset). Past several
|
||||
// would-be chain ticks (350/500/700/900/1100ms) the user's position
|
||||
// must hold instead of snapping back to the pin offset.
|
||||
await page.waitForTimeout(1_500);
|
||||
await expect.poll(sentTopInViewport).toBeGreaterThan(200);
|
||||
await page.waitForTimeout(400);
|
||||
await expect.poll(sentTopInViewport).toBeGreaterThan(200);
|
||||
|
||||
// The user is now free of the follow; the jump button shows.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Scroll to bottom" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await streamServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,338 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import {
|
||||
forwardRef,
|
||||
StrictMode,
|
||||
useImperativeHandle,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import type { VirtualMessageListHandle } from "@/components/workspace/messages/virtual-message-list";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { MessageGroup } from "@/core/messages/utils";
|
||||
|
||||
const mocks = rs.hoisted(() => ({
|
||||
pinnedGroups: [] as (string | null)[],
|
||||
}));
|
||||
|
||||
rs.mock("@/components/workspace/messages/message-group", () => ({
|
||||
MessageGroup: () => null,
|
||||
getMessageGroupReasoningMessage: () => undefined,
|
||||
}));
|
||||
rs.mock("@/components/ai-elements/conversation", () => ({
|
||||
ConversationScrollButton: () => null,
|
||||
Conversation: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ConversationContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
rs.mock("@/components/workspace/messages/virtual-message-list", () => ({
|
||||
VirtualMessageList: forwardRef<
|
||||
VirtualMessageListHandle,
|
||||
{
|
||||
groups: MessageGroup[];
|
||||
pinGroupKey?: string | null;
|
||||
renderGroup: (group: MessageGroup, index: number) => ReactNode;
|
||||
renderAfterGroup?: (index: number) => ReactNode;
|
||||
}
|
||||
>(function VirtualMessageListMock({ groups, pinGroupKey, renderGroup }, ref) {
|
||||
mocks.pinnedGroups.push(pinGroupKey ?? null);
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToGroup: () => undefined,
|
||||
cancelPinFollow: () => undefined,
|
||||
}));
|
||||
return (
|
||||
<div>
|
||||
{groups.map((group, index) => (
|
||||
<div key={`${group.type}:${group.id}`}>
|
||||
{renderGroup(group, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
rs.mock("@/components/workspace/messages/message-list-item", () => ({
|
||||
MessageListItem: ({ message }: { message: Message }) => (
|
||||
<div data-testid={`item-${message.type}`} />
|
||||
),
|
||||
}));
|
||||
rs.mock("@/components/workspace/messages/subtask-card", () => ({
|
||||
SubtaskCard: () => null,
|
||||
}));
|
||||
rs.mock("@/components/workspace/messages/scheduled-task-card", () => ({
|
||||
ScheduledTaskCard: () => null,
|
||||
taskPagePath: (id: string) => `/workspace/scheduled-tasks?task_id=${id}`,
|
||||
}));
|
||||
rs.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: ReactNode }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.pinnedGroups = [];
|
||||
});
|
||||
|
||||
const HUMAN_1 = { id: "human-1", type: "human", content: "First question" };
|
||||
const AI_1 = { id: "ai-1", type: "ai", content: "First answer" };
|
||||
const HUMAN_2 = {
|
||||
id: "local-human-2",
|
||||
type: "human",
|
||||
content: "Second question",
|
||||
};
|
||||
|
||||
function view({
|
||||
threadId,
|
||||
messages,
|
||||
isLoading,
|
||||
}: {
|
||||
threadId: string;
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
|
||||
>
|
||||
<MessageList
|
||||
threadId={threadId}
|
||||
thread={
|
||||
{
|
||||
messages,
|
||||
isLoading,
|
||||
isThreadLoading: false,
|
||||
values: {},
|
||||
getMessagesMetadata: () => undefined,
|
||||
} as unknown as React.ComponentProps<typeof MessageList>["thread"]
|
||||
}
|
||||
/>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function lastPinnedGroup(): string | null {
|
||||
return mocks.pinnedGroups.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function idle(threadId = "thread-1") {
|
||||
return view({
|
||||
threadId,
|
||||
messages: [HUMAN_1, AI_1] as Message[],
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("MessageList scroll-on-send", () => {
|
||||
it("pins a newly sent user turn to the top of the viewport", () => {
|
||||
const { rerender } = render(idle());
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
|
||||
// The send: an optimistic human message lands while the run starts.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
});
|
||||
|
||||
it("re-pins on a second send while the first run is still active", () => {
|
||||
const { rerender } = render(idle());
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
|
||||
// A queued second send appends another optimistic human turn; the pin
|
||||
// moves to the newest one.
|
||||
const HUMAN_3 = {
|
||||
id: "local-human-3",
|
||||
type: "human",
|
||||
content: "Third question",
|
||||
};
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2, HUMAN_3] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBe("local-human-3");
|
||||
});
|
||||
|
||||
it("keeps pinning the same turn while its reply streams in", () => {
|
||||
const { rerender } = render(idle());
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
|
||||
// Streaming chunks grow the reply without adding a human group.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [
|
||||
HUMAN_1,
|
||||
AI_1,
|
||||
HUMAN_2,
|
||||
{ id: "ai-2", type: "ai", content: "answer" },
|
||||
] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
});
|
||||
|
||||
it("does not pin when a run starts without a new user turn", () => {
|
||||
const { rerender } = render(idle());
|
||||
|
||||
// A regenerate: the run restarts on the existing turn.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pin when a regenerate masks the superseded assistant turn", () => {
|
||||
const { rerender } = render(idle());
|
||||
|
||||
// The real regenerate flow removes the superseded assistant messages
|
||||
// before the replay starts, so an EXISTING human turn becomes the last
|
||||
// group mid-run. Nothing was appended; there must be no pin.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pin when loading older history prepends messages", () => {
|
||||
const { rerender } = render(idle());
|
||||
|
||||
// History pagination prepends an older page; the last group is unchanged.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [
|
||||
{ id: "human-0", type: "human", content: "Older question" },
|
||||
HUMAN_1,
|
||||
AI_1,
|
||||
] as Message[],
|
||||
isLoading: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pin when mounting mid-run with a trailing human group", () => {
|
||||
// Restoring a thread (or opening a sidecar chat) whose run is already in
|
||||
// flight: the trailing human turn is not a fresh send.
|
||||
render(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pin when StrictMode double-runs the mount with a mid-run thread", () => {
|
||||
// React StrictMode (the App Router dev default) runs effects
|
||||
// mount → cleanup → mount with refs preserved. The mount absorption must
|
||||
// survive the second pass instead of falling through to the pin path.
|
||||
render(
|
||||
<StrictMode>
|
||||
{view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
})}
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the pin when the run finishes", () => {
|
||||
const { rerender } = render(idle());
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
|
||||
// The reply completed: the end spacer must collapse with the pin.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [
|
||||
HUMAN_1,
|
||||
AI_1,
|
||||
HUMAN_2,
|
||||
{ id: "ai-2", type: "ai", content: "final answer" },
|
||||
] as Message[],
|
||||
isLoading: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the pin when switching to another thread", () => {
|
||||
const { rerender } = render(idle());
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-1",
|
||||
messages: [HUMAN_1, AI_1, HUMAN_2] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
expect(lastPinnedGroup()).toBe("local-human-2");
|
||||
|
||||
// Opening another thread absorbs its last group instead of re-pinning.
|
||||
rerender(
|
||||
view({
|
||||
threadId: "thread-2",
|
||||
messages: [HUMAN_1] as Message[],
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastPinnedGroup()).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user