Skip to content
Open
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
52 changes: 46 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,20 @@ export default function ChatView(props: ChatViewProps) {
[activeThread],
);
const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
const [timelineAnchor, setTimelineAnchor] = useState<{
readonly threadKey: string | null;
readonly messageId: MessageId | null;
}>({ threadKey: activeThreadKey, messageId: null });
const timelineAnchorMessageId =
timelineAnchor.threadKey === activeThreadKey ? timelineAnchor.messageId : null;

useEffect(() => {
setTimelineAnchor((existing) =>
existing.threadKey === activeThreadKey
? existing
: { threadKey: activeThreadKey, messageId: null },
);
}, [activeThreadKey]);

useEffect(() => {
if (!activeThreadRef) {
Expand Down Expand Up @@ -1593,6 +1607,25 @@ export default function ChatView(props: ChatViewProps) {
);
const isSendBusy = isLocalDispatchBusy || isWorkspaceSwitching;
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
useEffect(() => {
if (!timelineAnchorMessageId) {
return;
}
if (isWorking || !latestTurnSettled || optimisticUserMessages.length > 0) {
return;
}
setTimelineAnchor((existing) =>
existing.threadKey === activeThreadKey && existing.messageId === timelineAnchorMessageId
? { threadKey: activeThreadKey, messageId: null }
: existing,
);
}, [
activeThreadKey,
isWorking,
latestTurnSettled,
optimisticUserMessages.length,
timelineAnchorMessageId,
]);
const canQueueMessages =
isServerThread && !isConnecting && !activeEnvironmentUnavailable && !activePendingProgress;

Expand Down Expand Up @@ -3088,13 +3121,16 @@ export default function ChatView(props: ChatViewProps) {
sizeBytes: file.sizeBytes,
})),
];
// Scroll to the current end *before* adding the optimistic message.
// This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd
// automatically pins to the new item when the data changes.
// Sending always returns to the live edge. Once the optimistic row is
// rendered, MessagesTimeline positions that sent message near the top and
// leaves room for the response to stream underneath it.
isAtEndRef.current = true;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
await legendListRef.current?.scrollToEnd?.({ animated: false });
setTimelineAnchor({
threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
messageId: messageIdForSend,
});

setOptimisticUserMessages((existing) => [
...existing,
Expand Down Expand Up @@ -3542,11 +3578,14 @@ export default function ChatView(props: ChatViewProps) {
beginLocalDispatch({ preparingWorktree: false });
setThreadError(threadIdForSend, null);

// Scroll to the current end *before* adding the optimistic message.
// Position the sent row once the optimistic message has rendered.
isAtEndRef.current = true;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
await legendListRef.current?.scrollToEnd?.({ animated: false });
setTimelineAnchor({
threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
messageId: messageIdForSend,
});

setOptimisticUserMessages((existing) => [
...existing,
Expand Down Expand Up @@ -4017,6 +4056,7 @@ export default function ChatView(props: ChatViewProps) {
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
anchorMessageId={timelineAnchorMessageId}
onIsAtEndChange={onIsAtEndChange}
/>

Expand Down
50 changes: 49 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import "../../index.css";

import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, MessageId } from "@t3tools/contracts";
import { createRef } from "react";
import type { LegendListRef } from "@legendapp/list/react";
import { page } from "vite-plus/test/browser";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";

const scrollToEndSpy = vi.fn();
const scrollToIndexSpy = vi.fn();
const getStateSpy = vi.fn(() => ({ isAtEnd: true }));
let lastLegendListProps: { maintainScrollAtEnd?: boolean } | null = null;

vi.mock("@legendapp/list/react", async () => {
const React = await import("react");
Expand All @@ -19,13 +21,16 @@ vi.mock("@legendapp/list/react", async () => {
renderItem: (args: { item: { id: string } }) => React.ReactNode;
ListHeaderComponent?: React.ReactNode;
ListFooterComponent?: React.ReactNode;
maintainScrollAtEnd?: boolean;
ref?: React.Ref<LegendListRef>;
}) {
lastLegendListProps = props;
React.useImperativeHandle(
props.ref,
() =>
({
scrollToEnd: scrollToEndSpy,
scrollToIndex: scrollToIndexSpy,
getState: getStateSpy,
}) as unknown as LegendListRef,
);
Expand Down Expand Up @@ -69,6 +74,7 @@ function buildProps() {
resolvedTheme: "dark" as const,
timestampFormat: "24-hour" as const,
workspaceRoot: undefined,
anchorMessageId: null,
onIsAtEndChange: vi.fn(),
};
}
Expand Down Expand Up @@ -97,7 +103,9 @@ function buildUserTimelineEntry(text: string) {
describe("MessagesTimeline", () => {
afterEach(() => {
scrollToEndSpy.mockReset();
scrollToIndexSpy.mockReset();
getStateSpy.mockClear();
lastLegendListProps = null;
vi.restoreAllMocks();
document.body.innerHTML = "";
});
Expand Down Expand Up @@ -179,6 +187,46 @@ describe("MessagesTimeline", () => {
}
});

it("positions an anchored sent message without maintaining the live edge", async () => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
(callback: FrameRequestCallback) => {
callback(0);
return 1;
},
);
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);

const anchoredMessageId = MessageId.make("message-1");
const screen = await render(
<MessagesTimeline
{...buildProps()}
activeTurnInProgress
anchorMessageId={anchoredMessageId}
timelineEntries={[
{
...buildUserTimelineEntry("Ship the thing."),
message: {
...buildUserTimelineEntry("Ship the thing.").message,
id: anchoredMessageId,
},
},
]}
/>,
);

try {
expect(lastLegendListProps?.maintainScrollAtEnd).toBe(false);
expect(scrollToIndexSpy).toHaveBeenCalledWith({
index: 0,
animated: true,
viewPosition: 0,
viewOffset: 16,
});
} finally {
await screen.unmount();
}
});

it("starts long user messages collapsed by default", async () => {
const screen = await render(
<MessagesTimeline
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function buildProps() {
resolvedTheme: "light" as const,
timestampFormat: "locale" as const,
workspaceRoot: undefined,
anchorMessageId: null,
onIsAtEndChange: () => {},
};
}
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ServerProviderSkill,
type TurnId,
} from "@t3tools/contracts";
import { resolveChatListAnchorPosition } from "@t3tools/shared/chatList";
import {
createContext,
memo,
Expand Down Expand Up @@ -108,6 +109,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
const TIMELINE_ANCHORED_LIST_FOOTER = <div className="h-[55vh]" />;
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -136,6 +138,7 @@ interface MessagesTimelineProps {
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
anchorMessageId: MessageId | null;
onIsAtEndChange: (isAtEnd: boolean) => void;
}

Expand Down Expand Up @@ -165,6 +168,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timestampFormat,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
anchorMessageId,
onIsAtEndChange,
}: MessagesTimelineProps) {
const rawRows = useMemo(
Expand Down Expand Up @@ -193,6 +197,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
],
);
const rows = useStableRows(rawRows);
const anchorPosition = useMemo(
() =>
resolveChatListAnchorPosition(rows, anchorMessageId, (row) =>
row.kind === "message" ? row.message.id : null,
),
[anchorMessageId, rows],
);
const anchorScrollKey = anchorPosition
? `${anchorMessageId}:${anchorPosition.anchorIndex}:${anchorPosition.anchorOffset}`
: null;

const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
Expand All @@ -219,6 +233,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({
};
}, [listRef, onIsAtEndChange, rows.length]);

useEffect(() => {
if (!anchorPosition || !anchorMessageId) {
return;
}

let cancelled = false;
let secondFrameId: number | null = null;
const firstFrameId = window.requestAnimationFrame(() => {
secondFrameId = window.requestAnimationFrame(() => {
if (cancelled) {
return;
}
void listRef.current?.scrollToIndex?.({
index: anchorPosition.anchorIndex,
animated: true,
viewPosition: 0,
viewOffset: anchorPosition.anchorOffset,
});
});
});

return () => {
cancelled = true;
window.cancelAnimationFrame(firstFrameId);
if (secondFrameId !== null) {
window.cancelAnimationFrame(secondFrameId);
}
};
}, [anchorMessageId, anchorPosition, anchorScrollKey, listRef]);

const sharedState = useMemo<TimelineRowSharedState>(
() => ({
timestampFormat,
Expand Down Expand Up @@ -284,13 +328,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
maintainScrollAtEnd
maintainScrollAtEnd={!anchorMessageId}
maintainScrollAtEndThreshold={0.1}
maintainVisibleContentPosition
onScroll={handleScroll}
className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
ListHeaderComponent={TIMELINE_LIST_HEADER}
ListFooterComponent={TIMELINE_LIST_FOOTER}
ListFooterComponent={
anchorMessageId && (activeTurnInProgress || anchorPosition)
? TIMELINE_ANCHORED_LIST_FOOTER
: TIMELINE_LIST_FOOTER
}
/>
</TimelineRowActivityCtx>
</TimelineRowCtx>
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@
"types": "./src/composerTrigger.ts",
"import": "./src/composerTrigger.ts"
},
"./chatList": {
"types": "./src/chatList.ts",
"import": "./src/chatList.ts"
},
"./terminalLabels": {
"types": "./src/terminalLabels.ts",
"import": "./src/terminalLabels.ts"
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/chatList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vite-plus/test";

import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchorPosition } from "./chatList.ts";

describe("resolveChatListAnchorPosition", () => {
it("returns undefined when no anchor is requested", () => {
expect(resolveChatListAnchorPosition([{ id: "a" }], null, (item) => item.id)).toBeUndefined();
});

it("finds the last matching anchor item", () => {
expect(
resolveChatListAnchorPosition(
[{ id: "sent" }, { id: "assistant" }, { id: "sent" }],
"sent",
(item) => item.id,
),
).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});

it("uses a custom anchor offset", () => {
expect(
resolveChatListAnchorPosition([{ id: "sent" }], "sent", (item) => item.id, {
anchorOffset: 24,
}),
).toEqual({
anchorIndex: 0,
anchorOffset: 24,
});
});
});
33 changes: 33 additions & 0 deletions packages/shared/src/chatList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const CHAT_LIST_ANCHOR_OFFSET = 16;

export interface ChatListAnchorPosition {
readonly anchorIndex: number;
readonly anchorOffset: number;
}

export interface ChatListAnchorOptions {
readonly anchorOffset?: number;
}

export function resolveChatListAnchorPosition<Item, AnchorId>(
items: ReadonlyArray<Item>,
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: ChatListAnchorOptions = {},
): ChatListAnchorPosition | undefined {
if (anchorId === null) {
return undefined;
}

for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}

return undefined;
}
Loading