Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -723,34 +723,6 @@ describe("MessagesTimeline messages", () => {
expect(maxNestedCommits).toBeLessThan(20);
});

it("stays at the end in the frame the viewport shrinks", async () => {
setup();
const props = baseProps();
const entries = Array.from({ length: 40 }, (_, index) =>
message(`Turn ${index}: ${"x ".repeat(200)}`, {}, `turn-${index}`),
);
await using screen = await renderElement(
<MessagesTimeline {...props} timelineEntries={entries} />,
);
const viewport = props.listRef.current!.getScrollableNode()!;
const distanceFromEnd = () =>
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
await vi.waitFor(() => {
expect(screen.text()).toContain("Turn 39:");
expect(distanceFromEnd()).toBeLessThan(2);
expect(props.listRef.current!.getState().isWithinMaintainScrollAtEndThreshold).toBe(true);
});
const distances: number[] = [];
const resizes = new ResizeObserver(() => distances.push(distanceFromEnd()));
resizes.observe(viewport);
using _resizes = { [Symbol.dispose]: () => resizes.disconnect() };
await vi.waitFor(() => expect(distances).toHaveLength(1));

screen.container.style.height = "880px";
await vi.waitFor(() => expect(distances).toHaveLength(2));
expect(distances[1]).toBeLessThan(2);
});

it("keeps the first and last message headings reachable from either end", async () => {
setup();
const props = baseProps();
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,42 @@ const ConversationTimeline = memo(function ConversationTimeline({
scrollNode.scrollTop = scrollNode.scrollHeight - scrollNode.clientHeight;
});

const lastUserRowId = timelineEntries.findLast(
(row) => row.kind === "message" && row.message.role === "user",
)?.id;
const revealNewUserMessage = useStableCallback(
(previous: { identity: string; rowId: string | undefined }, list: LegendListRef) => {
if (previous.identity !== listIdentityKey || !liveFollowEnabled) {
previous.identity = listIdentityKey;
previous.rowId = lastUserRowId;
return;
}
if (previous.rowId === lastUserRowId || !lastUserRowId) return;
const state = list.getState();
if (state.data !== rows) return;
const index = state.indexByKey(lastUserRowId);
const row = index === undefined ? undefined : state.elementAtIndex(index);
const element = list.getScrollableNode();
if (!row || !element || row.getBoundingClientRect().height === 0) return;
previous.rowId = lastUserRowId;
const end = Math.max(0, element.scrollHeight - element.clientHeight);
const offset = anchoredEndSpace
? Math.max(
0,
Math.min(
end,
element.scrollTop +
row.getBoundingClientRect().top -
element.getBoundingClientRect().top -
CHAT_TIMELINE_ANCHOR_OFFSET,
),
)
: end;
element.scrollTop = offset;
void list.scrollToOffset({ offset, animated: false });
},
);

const registerTimeline = useRegisterChatCanvasTimeline();
// Stable so the list attaches once per mount, not on every render.
const setTimelineList = useStableCallback((list: LegendListRef | null) => {
Expand All @@ -1290,11 +1326,13 @@ const ConversationTimeline = memo(function ConversationTimeline({
viewportRef.current = viewport;
const detachViewport = viewport ? onViewportAttach?.(viewport) : undefined;
const stopRememberingRelease = viewport?.onFollowReleased(rememberReleasedPosition);
const previousUserMessage = { identity: listIdentityKey, rowId: lastUserRowId };
let contentSizeFrame: number | null = null;
// LegendList lays rows out from layout effects; the next frame sees them settled.
let scheduledData: unknown = null;
const scheduleContentSizeChanged = () => {
if (!list) return;
revealNewUserMessage(previousUserMessage, list);
const laidOutData = list.getState().data;
if (contentSizeFrame !== null) {
if (scheduledData === laidOutData) return;
Expand Down
23 changes: 7 additions & 16 deletions apps/web/src/components/chat/composerSubmission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,27 +60,18 @@ describe("submitComposerDraft", () => {
expect(validationMessage).toBeNull();
});

it("keeps the next message and explains that the previous send is still saving", () => {
it("keeps the next message while the previous send is still saving", () => {
const onSend = vi.fn();
const preventDefault = vi.fn();
const input = {
const result = submitComposerDraft({
prompt: "Message B",
submissionTarget: "provider-turn" as const,
event: { preventDefault },
isSendBusy: true,
submissionTarget: "provider-turn",
event: { preventDefault: () => {} },
onSend,
};
expect(submitComposerDraft({ ...input, isSendBusy: true })).toEqual({
validationMessage:
"Your previous message is still saving. Wait for it to finish, then send again.",
didDispatch: false,
});
expect(result.didDispatch).toBe(false);
expect(result.validationMessage).not.toBeNull();
expect(onSend).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledOnce();
expect(submitComposerDraft({ ...input, isSendBusy: false })).toEqual({
validationMessage: null,
didDispatch: true,
});
expect(onSend).toHaveBeenCalledOnce();
});

it("blocks when appended context pushes the provider input over the shared limit", () => {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/useTimelinePositionRestore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { observeResize } from "~/lib/observeResize";
import type { LegendListRef } from "@legendapp/list/react";
import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
import type { RememberedTimelinePosition } from "./timelineScrollAnchoring";
Expand Down Expand Up @@ -87,8 +88,20 @@ export function startTimelinePositionRestore({
// Reading history or finding: follow lets go before the restore claims the viewport.
if (position?.atEnd === false || releaseForFind) viewport.navigate({ releasesFollow: true });
const releaseScrollHold = viewport.holdProgrammaticScroll();
const element = list.getScrollableNode();
const content = element?.firstElementChild;
const stopObserving =
position?.atEnd !== false && content && element
? observeResize(content, () => {
const offset = measuredEndOffset(element);
if (Math.abs(element.scrollTop - offset) <= 1) return;
element.scrollTop = offset;
void list.scrollToOffset({ offset, animated: false });
})
: undefined;
const stop = () => {
cancelled = true;
stopObserving?.();
releaseScrollHold();
unsubscribe();
};
Expand Down
Loading