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
1 change: 0 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,7 @@
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
focusComposer();

Check warning on line 1539 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
Expand All @@ -1544,7 +1544,7 @@
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
if (!activeThreadRef) return;

Check warning on line 1547 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
storeSetTerminalOpen(activeThreadRef, open);
},
[activeThreadRef, storeSetTerminalOpen],
Expand Down Expand Up @@ -2726,7 +2726,7 @@
) {
composerRef.current?.focusAt(nextCursor);
}
},

Check warning on line 2729 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
[activePendingUserInput],
);

Expand All @@ -2753,7 +2753,7 @@
if (!activePendingProgress) {
return;
}
setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0));

Check warning on line 2756 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, [activePendingProgress, setActivePendingUserInputQuestionIndex]);

const onSubmitPlanFollowUp = useCallback(
Expand Down Expand Up @@ -2816,7 +2816,7 @@

setOptimisticUserMessages((existing) => [
...existing,
{

Check warning on line 2819 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
id: messageIdForSend,
role: "user",
text: outgoingMessageText,
Expand Down Expand Up @@ -2951,7 +2951,7 @@

await api.orchestration
.dispatchCommand({
type: "thread.create",

Check warning on line 2954 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
commandId: newCommandId(),
threadId: nextThreadId,
projectId: activeProject.id,
Expand Down Expand Up @@ -3186,7 +3186,6 @@
{/* Messages — LegendList handles virtualization and scrolling internally */}
<MessagesTimeline
key={activeThread.id}
hasMessages={timelineEntries.length > 0}
isWorking={isWorking}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnId={activeLatestTurn?.turnId ?? null}
Expand Down
160 changes: 160 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import "../../index.css";

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

const scrollToEndSpy = vi.fn();
const getStateSpy = vi.fn(() => ({ isAtEnd: true }));

vi.mock("@legendapp/list/react", async () => {
const React = await import("react");

const LegendList = React.forwardRef(function MockLegendList(
props: {
data: Array<{ id: string }>;
keyExtractor: (item: { id: string }) => string;
renderItem: (args: { item: { id: string } }) => React.ReactNode;
ListHeaderComponent?: React.ReactNode;
ListFooterComponent?: React.ReactNode;
},
ref: React.ForwardedRef<LegendListRef>,
) {
React.useImperativeHandle(
ref,
() =>
({
scrollToEnd: scrollToEndSpy,
getState: getStateSpy,
}) as unknown as LegendListRef,
);

return (
<div data-testid="legend-list">
{props.ListHeaderComponent}
{props.data.map((item) => (
<div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
))}
{props.ListFooterComponent}
</div>
);
});

return { LegendList };
});

import { MessagesTimeline } from "./MessagesTimeline";

function buildProps() {
return {
isWorking: false,
activeTurnInProgress: false,
activeTurnId: null,
activeTurnStartedAt: null,
listRef: createRef<LegendListRef | null>(),
completionDividerBeforeEntryId: null,
completionSummary: null,
turnDiffSummaryByAssistantMessageId: new Map(),
routeThreadKey: "environment-local:thread-1",
onOpenTurnDiff: vi.fn(),
revertTurnCountByUserMessageId: new Map(),
onRevertUserMessage: vi.fn(),
isRevertingCheckpoint: false,
onImageExpand: vi.fn(),
activeThreadEnvironmentId: EnvironmentId.make("environment-local"),
markdownCwd: undefined,
resolvedTheme: "dark" as const,
timestampFormat: "24-hour" as const,
workspaceRoot: undefined,
onIsAtEndChange: vi.fn(),
};
}

describe("MessagesTimeline", () => {
afterEach(() => {
scrollToEndSpy.mockReset();
getStateSpy.mockClear();
vi.restoreAllMocks();
document.body.innerHTML = "";
});

it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => {
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "work-1",
kind: "work",
createdAt: "2026-04-13T12:00:00.000Z",
entry: {
id: "work-1",
createdAt: "2026-04-13T12:00:00.000Z",
label: "thinking",
detail: "Inspecting repository state",
tone: "thinking",
},
},
]}
/>,
);

try {
await expect
.element(page.getByText("Send a message to start the conversation."))
.not.toBeInTheDocument();
await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
} finally {
await screen.unmount();
}
});

it("snaps to the bottom when timeline rows appear after an initially empty render", async () => {
const requestAnimationFrameSpy = vi
.spyOn(window, "requestAnimationFrame")
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);

const props = buildProps();
const screen = await render(<MessagesTimeline {...props} timelineEntries={[]} />);

try {
await expect
.element(page.getByText("Send a message to start the conversation."))
.toBeVisible();

await screen.rerender(
<MessagesTimeline
{...props}
timelineEntries={[
{
id: "work-1",
kind: "work",
createdAt: "2026-04-13T12:00:00.000Z",
entry: {
id: "work-1",
createdAt: "2026-04-13T12:00:00.000Z",
label: "thinking",
detail: "Inspecting repository state",
tone: "thinking",
},
},
]}
/>,
);

await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
expect(props.onIsAtEndChange).toHaveBeenCalledWith(true);
expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false });
expect(requestAnimationFrameSpy).toHaveBeenCalled();
} finally {
await screen.unmount();
}
});
});
22 changes: 19 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
// ---------------------------------------------------------------------------

interface MessagesTimelineProps {
hasMessages: boolean;
isWorking: boolean;
activeTurnInProgress: boolean;
activeTurnId?: TurnId | null;
Expand Down Expand Up @@ -121,7 +120,6 @@ interface MessagesTimelineProps {
// ---------------------------------------------------------------------------

export const MessagesTimeline = memo(function MessagesTimeline({
hasMessages,
isWorking,
activeTurnInProgress,
activeTurnId,
Expand Down Expand Up @@ -172,6 +170,24 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}
}, [listRef, onIsAtEndChange]);

const previousRowCountRef = useRef(rows.length);
useEffect(() => {
const previousRowCount = previousRowCountRef.current;
previousRowCountRef.current = rows.length;

if (previousRowCount > 0 || rows.length === 0) {
return;
}

onIsAtEndChange(true);
const frameId = window.requestAnimationFrame(() => {
void listRef.current?.scrollToEnd?.({ animated: false });
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [listRef, onIsAtEndChange, rows.length]);

// Memoised context value — only changes on state transitions, NOT on
// every streaming chunk. Callbacks from ChatView are useCallback-stable.
const sharedState = useMemo<TimelineRowSharedState>(
Expand Down Expand Up @@ -220,7 +236,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
[],
);

if (!hasMessages && !isWorking) {
if (rows.length === 0 && !isWorking) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Expand Down
Loading