mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
fix(frontend): performance and loading state bugs (#12821)
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { render } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useParamsMock, createUserMessageEvent } from "test-utils";
|
||||
import { ChatInterface } from "#/components/features/chat/chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Module-level mocks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-unified-upload-files");
|
||||
vi.mock("#/hooks/use-conversation-id");
|
||||
vi.mock("#/hooks/query/use-active-conversation");
|
||||
vi.mock("#/contexts/conversation-websocket-context");
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
useConversationNameContextMenu: () => ({
|
||||
isOpen: false,
|
||||
contextMenuRef: { current: null },
|
||||
handleContextMenu: vi.fn(),
|
||||
handleClose: vi.fn(),
|
||||
handleRename: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(() => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Helper to render with QueryClient and route params
|
||||
const renderWithQueryClient = (
|
||||
ui: React.ReactElement,
|
||||
queryClient: QueryClient,
|
||||
route = "/test-conversation-id",
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="/:conversationId" element={ui} />
|
||||
<Route path="/" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// V0 user event (numeric id, action property)
|
||||
const createV0UserEvent = (): OpenHandsAction => ({
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: {
|
||||
content: "Hello from V0",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
message: "Hello from V0",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
});
|
||||
|
||||
describe("ChatInterface – message display continuity (spec 3.1)", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
|
||||
vi.mocked(useConversationId).mockReturnValue({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
|
||||
// Default: V0, no loading, no events
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { app_mode: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(
|
||||
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Default: no conversation (V0 behavior)
|
||||
vi.mocked(useActiveConversation).mockReturnValue({
|
||||
data: undefined,
|
||||
} as ReturnType<typeof useActiveConversation>);
|
||||
|
||||
// Default: no websocket context
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("V1 conversations", () => {
|
||||
beforeEach(() => {
|
||||
// Set up V1 conversation
|
||||
vi.mocked(useActiveConversation).mockReturnValue({
|
||||
data: { conversation_version: "V1" },
|
||||
} as ReturnType<typeof useActiveConversation>);
|
||||
});
|
||||
|
||||
it("shows messages immediately when V1 events exist in store, even while loading", () => {
|
||||
// Simulate: history is loading but events already exist in store (e.g., remount)
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: true,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// Put V1 user events in the store
|
||||
const v1UserEvent = createUserMessageEvent("evt-1");
|
||||
useEventStore.setState({
|
||||
events: [v1UserEvent],
|
||||
uiEvents: [v1UserEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC1: Messages should display immediately without skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton when store is empty and loading", () => {
|
||||
// Simulate: first load, no events yet
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: true,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// Store is empty
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC5: Genuine first-load shows skeleton
|
||||
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows messages when loading is already false on mount (edge case)", () => {
|
||||
// Simulate: component re-mounts when WebSocket has already finished loading
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: false,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// V1 events in store
|
||||
const v1UserEvent = createUserMessageEvent("evt-2");
|
||||
useEventStore.setState({
|
||||
events: [v1UserEvent],
|
||||
uiEvents: [v1UserEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Messages should display, no skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("V0 conversations", () => {
|
||||
it("shows messages when V0 events exist in store even if isLoadingMessages is true", () => {
|
||||
// Simulate: loading flag is still true but events already exist in store (e.g., remount)
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
// Put V0 user events in the store
|
||||
useEventStore.setState({
|
||||
events: [createV0UserEvent()],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC1/AC4: Messages display immediately, no skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton when store is empty and isLoadingMessages is true", () => {
|
||||
// Simulate: genuine first load, no events yet
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
// Store is empty
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC5: Genuine first-load shows skeleton
|
||||
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mutable mock state for controlling breakpoint
|
||||
let mockIsMobile = false;
|
||||
|
||||
// Track ChatInterface unmount via vi.fn()
|
||||
const chatInterfaceUnmount = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-breakpoint", () => ({
|
||||
useBreakpoint: () => mockIsMobile,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-resizable-panels", () => ({
|
||||
useResizablePanels: () => ({
|
||||
leftWidth: 50,
|
||||
rightWidth: 50,
|
||||
isDragging: false,
|
||||
containerRef: { current: null },
|
||||
handleMouseDown: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/stores/conversation-store", () => ({
|
||||
useConversationStore: () => ({
|
||||
isRightPanelShown: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock ChatInterface with useEffect to track mount/unmount lifecycle
|
||||
vi.mock("#/components/features/chat/chat-interface", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const React = require("react");
|
||||
return {
|
||||
ChatInterface: () => {
|
||||
React.useEffect(() => {
|
||||
return () => chatInterfaceUnmount();
|
||||
}, []);
|
||||
return <div data-testid="chat-interface">Chat Interface</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content",
|
||||
() => ({
|
||||
ConversationTabContent: () => <div data-testid="tab-content" />,
|
||||
}),
|
||||
);
|
||||
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
|
||||
|
||||
describe("ConversationMain - Layout Transition Stability", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMobile = false;
|
||||
chatInterfaceUnmount.mockClear();
|
||||
});
|
||||
|
||||
it("renders ChatInterface at desktop width", () => {
|
||||
mockIsMobile = false;
|
||||
render(<ConversationMain />);
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ChatInterface at mobile width", () => {
|
||||
mockIsMobile = true;
|
||||
render(<ConversationMain />);
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not unmount ChatInterface when crossing from desktop to mobile", () => {
|
||||
mockIsMobile = false;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
|
||||
// Cross the breakpoint to mobile
|
||||
mockIsMobile = true;
|
||||
rerender(<ConversationMain />);
|
||||
|
||||
// ChatInterface must NOT have been unmounted and remounted
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not unmount ChatInterface when crossing from mobile to desktop", () => {
|
||||
mockIsMobile = true;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
|
||||
// Cross the breakpoint to desktop
|
||||
mockIsMobile = false;
|
||||
rerender(<ConversationMain />);
|
||||
|
||||
// ChatInterface must NOT have been unmounted and remounted
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("survives rapid back-and-forth resize without unmounting ChatInterface", () => {
|
||||
mockIsMobile = false;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
|
||||
// Simulate rapid resize back and forth across the breakpoint
|
||||
for (const mobile of [true, false, true, false, true]) {
|
||||
mockIsMobile = mobile;
|
||||
rerender(<ConversationMain />);
|
||||
}
|
||||
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -112,3 +112,192 @@ describe("useConversationHistory", () => {
|
||||
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useConversationHistory cache key stability", () => {
|
||||
let localQueryClient: QueryClient;
|
||||
let localWrapper: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => React.ReactElement;
|
||||
|
||||
beforeEach(() => {
|
||||
localQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
localWrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: localQueryClient },
|
||||
children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not refetch when conversation object changes but version stays the same", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
const conv1 = makeConversation("V1");
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: conv1,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useConversationHistory("conv-stable"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate background polling: new object reference with different mutable fields
|
||||
// but the SAME conversation_version
|
||||
const conv2: Conversation = {
|
||||
...conv1,
|
||||
last_updated_at: "2099-01-01T00:00:00Z",
|
||||
status: "STOPPED",
|
||||
runtime_status: "STATUS$STOPPED",
|
||||
};
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: conv2,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
rerender();
|
||||
|
||||
// Allow any potential async refetch to trigger
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 50);
|
||||
});
|
||||
|
||||
// Must NOT refetch — version hasn't changed, only mutable fields did
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Edge case: version change MUST trigger a refetch with the correct endpoint
|
||||
it("refetches when conversation_version changes from V0 to V1", async () => {
|
||||
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v0Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
// Start with V0
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V0"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useConversationHistory("conv-version-change"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(v0Spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Switch to V1 — new version means new cache key, must refetch
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cached history as never stale (staleTime is Infinity)", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationHistory("conv-stale-check"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
// Check the query's staleTime option in the cache
|
||||
const queries = localQueryClient.getQueryCache().findAll({
|
||||
queryKey: ["conversation-history", "conv-stale-check"],
|
||||
});
|
||||
expect(queries).toHaveLength(1);
|
||||
expect((queries[0].options as Record<string, unknown>).staleTime).toBe(
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationHistory("conv-gc-check"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
const queries = localQueryClient.getQueryCache().findAll({
|
||||
queryKey: ["conversation-history", "conv-gc-check"],
|
||||
});
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].options.gcTime).toBeGreaterThanOrEqual(30 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
180
frontend/__tests__/hooks/use-breakpoint.test.ts
Normal file
180
frontend/__tests__/hooks/use-breakpoint.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
|
||||
// Helper to set window.innerWidth and dispatch resize event
|
||||
function setWindowWidth(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
|
||||
describe("useBreakpoint", () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
|
||||
beforeEach(() => {
|
||||
// Start at a known desktop width
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalInnerWidth,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false (not mobile) when window width is above the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true (mobile) when window width is at the breakpoint (1024)", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1024 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true (mobile) when window width is below the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates from false to true when window resizes below the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(800);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates from true to false when window resizes above the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger re-render when width changes within the desktop range", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const renderCount = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renderCount();
|
||||
return useBreakpoint();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
const initialRenderCount = renderCount.mock.calls.length;
|
||||
|
||||
// Resize within desktop range (still above 1024) — should NOT re-render
|
||||
act(() => {
|
||||
setWindowWidth(1300);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1100);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1025);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
// No additional renders beyond the initial render
|
||||
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
|
||||
});
|
||||
|
||||
it("does NOT trigger re-render when width changes within the mobile range", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const renderCount = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renderCount();
|
||||
return useBreakpoint();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
const initialRenderCount = renderCount.mock.calls.length;
|
||||
|
||||
// Resize within mobile range (still at or below 1024) — should NOT re-render
|
||||
act(() => {
|
||||
setWindowWidth(600);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1024);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(900);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
|
||||
});
|
||||
|
||||
it("handles rapid resize across the breakpoint without issues", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Rapid toggles across the breakpoint
|
||||
act(() => {
|
||||
setWindowWidth(800);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1200);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1024);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1025);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up the resize event listener on unmount", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = renderHook(() => useBreakpoint());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function),
|
||||
);
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("accepts a custom breakpoint value", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 768 });
|
||||
const { result } = renderHook(() => useBreakpoint(768));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(769);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
383
frontend/__tests__/hooks/use-filtered-events.test.ts
Normal file
383
frontend/__tests__/hooks/use-filtered-events.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useFilteredEvents } from "#/hooks/use-filtered-events";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OpenHandsAction } from "#/types/core/actions";
|
||||
import type { ActionEvent, MessageEvent } from "#/types/v1/core";
|
||||
import { SecurityRisk } from "#/types/v1/core";
|
||||
|
||||
// --- V0 event factories ---
|
||||
|
||||
function createV0UserMessage(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: { content: `User message ${id}`, image_urls: [], file_urls: [] },
|
||||
message: `User message ${id}`,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function createV0AgentMessage(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
action: "message",
|
||||
args: {
|
||||
thought: `Agent thought ${id}`,
|
||||
image_urls: null,
|
||||
file_urls: [],
|
||||
wait_for_response: true,
|
||||
},
|
||||
message: `Agent response ${id}`,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function createV0SystemEvent(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "environment",
|
||||
action: "system",
|
||||
args: {
|
||||
content: "source .openhands/setup.sh",
|
||||
tools: null,
|
||||
openhands_version: null,
|
||||
agent_class: null,
|
||||
},
|
||||
message: "Running setup script",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- V1 event factories ---
|
||||
|
||||
function createV1UserMessage(id: string): MessageEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: "2025-07-01T00:00:01Z",
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `User message ${id}` }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createV1AgentAction(id: string): ActionEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: "2025-07-01T00:00:02Z",
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "Agent thought" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo test",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call-1",
|
||||
tool_call: {
|
||||
id: "call-1",
|
||||
type: "function",
|
||||
function: { name: "execute_bash", arguments: '{"command": "echo test"}' },
|
||||
},
|
||||
llm_response_id: "response-1",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the event store before each test
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilteredEvents", () => {
|
||||
describe("referential stability", () => {
|
||||
it("returns the same v0Events reference when storeEvents has not changed", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
useEventStore.setState({
|
||||
events: [v0Event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [v0Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV0Events = result.current.v0Events;
|
||||
|
||||
// Rerender without changing the store
|
||||
rerender();
|
||||
|
||||
expect(result.current.v0Events).toBe(firstV0Events);
|
||||
});
|
||||
|
||||
it("returns the same v1UiEvents reference when uiEvents has not changed", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV1UiEvents = result.current.v1UiEvents;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.v1UiEvents).toBe(firstV1UiEvents);
|
||||
});
|
||||
|
||||
it("returns the same v1FullEvents reference when storeEvents has not changed", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV1FullEvents = result.current.v1FullEvents;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.v1FullEvents).toBe(firstV1FullEvents);
|
||||
});
|
||||
|
||||
it("returns a new v0Events reference when storeEvents changes", () => {
|
||||
const v0Event1 = createV0UserMessage(1);
|
||||
useEventStore.setState({
|
||||
events: [v0Event1],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [v0Event1],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
const firstV0Events = result.current.v0Events;
|
||||
|
||||
// Add a new event to the store (new array reference)
|
||||
const v0Event2 = createV0AgentMessage(2);
|
||||
act(() => {
|
||||
useEventStore.setState({
|
||||
events: [v0Event1, v0Event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [v0Event1, v0Event2],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.v0Events).not.toBe(firstV0Events);
|
||||
expect(result.current.v0Events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V0 event filtering", () => {
|
||||
it("filters V0 events through isV0Event, isActionOrObservation, and shouldRenderEvent", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
const agentMsg = createV0AgentMessage(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg, agentMsg],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [userMsg, agentMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toHaveLength(2);
|
||||
expect(result.current.v0Events).toContainEqual(userMsg);
|
||||
expect(result.current.v0Events).toContainEqual(agentMsg);
|
||||
});
|
||||
|
||||
it("excludes V0 system events from v0Events", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
const systemEvent = createV0SystemEvent(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg, systemEvent],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [userMsg, systemEvent],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
// System events are filtered out by shouldRenderEvent
|
||||
expect(result.current.v0Events).toHaveLength(1);
|
||||
expect(result.current.v0Events[0]).toEqual(userMsg);
|
||||
});
|
||||
|
||||
it("does not include V1 events in v0Events", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event, v1Event],
|
||||
eventIds: new Set([1, "msg-1"]),
|
||||
uiEvents: [v0Event, v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toHaveLength(1);
|
||||
expect(result.current.v0Events[0]).toEqual(v0Event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 event filtering", () => {
|
||||
it("filters V1 events into v1FullEvents", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v1FullEvents).toHaveLength(1);
|
||||
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
|
||||
});
|
||||
|
||||
it("does not include V0 events in v1FullEvents", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event, v1Event],
|
||||
eventIds: new Set([1, "msg-1"]),
|
||||
uiEvents: [v0Event, v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v1FullEvents).toHaveLength(1);
|
||||
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalEvents", () => {
|
||||
it("returns V0 event count when V0 events exist", () => {
|
||||
const v0Event1 = createV0UserMessage(1);
|
||||
const v0Event2 = createV0AgentMessage(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event1, v0Event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [v0Event1, v0Event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.totalEvents).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.totalEvents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSubstantiveAgentActions", () => {
|
||||
it("returns false when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when only user events exist (V0)", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when V0 agent message actions exist", () => {
|
||||
const agentMsg = createV0AgentMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [agentMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [agentMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when V1 agent action events exist", () => {
|
||||
const agentAction = createV1AgentAction("action-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [agentAction],
|
||||
eventIds: new Set(["action-1"]),
|
||||
uiEvents: [agentAction],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userEventsExist", () => {
|
||||
it("returns false when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.userEventsExist).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when V0 user events exist", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.v0UserEventsExist).toBe(true);
|
||||
expect(result.current.userEventsExist).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when V1 user events exist", () => {
|
||||
const userMsg = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.v1UserEventsExist).toBe(true);
|
||||
expect(result.current.userEventsExist).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty store", () => {
|
||||
it("returns empty arrays and false flags for empty store", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toEqual([]);
|
||||
expect(result.current.v1UiEvents).toEqual([]);
|
||||
expect(result.current.v1FullEvents).toEqual([]);
|
||||
expect(result.current.totalEvents).toBe(0);
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
expect(result.current.v0UserEventsExist).toBe(false);
|
||||
expect(result.current.v1UserEventsExist).toBe(false);
|
||||
expect(result.current.userEventsExist).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
126
frontend/__tests__/hooks/use-scroll-to-bottom.test.ts
Normal file
126
frontend/__tests__/hooks/use-scroll-to-bottom.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Creates a mock scroll element with a trackable scrollTop setter.
|
||||
*
|
||||
* state.scrollTop can be set directly (bypassing the spy) to position
|
||||
* the element for onChatBodyScroll calls without polluting the spy.
|
||||
*/
|
||||
function createMockScrollElement(initialScrollHeight = 1000) {
|
||||
const state = {
|
||||
scrollTop: 0,
|
||||
scrollHeight: initialScrollHeight,
|
||||
clientHeight: 500,
|
||||
};
|
||||
|
||||
const scrollTopSetter = vi.fn((value: number) => {
|
||||
state.scrollTop = value;
|
||||
});
|
||||
|
||||
const element = {
|
||||
get scrollTop() {
|
||||
return state.scrollTop;
|
||||
},
|
||||
set scrollTop(value: number) {
|
||||
scrollTopSetter(value);
|
||||
},
|
||||
get scrollHeight() {
|
||||
return state.scrollHeight;
|
||||
},
|
||||
get clientHeight() {
|
||||
return state.clientHeight;
|
||||
},
|
||||
} as unknown as HTMLDivElement;
|
||||
|
||||
return { element, scrollTopSetter, state };
|
||||
}
|
||||
|
||||
describe("useScrollToBottom", () => {
|
||||
let mock: ReturnType<typeof createMockScrollElement>;
|
||||
let ref: RefObject<HTMLDivElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createMockScrollElement(1000);
|
||||
ref = { current: mock.element } as RefObject<HTMLDivElement>;
|
||||
});
|
||||
|
||||
describe("no automatic scrolling on render", () => {
|
||||
it("does NOT scroll on initial render", () => {
|
||||
renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// No useLayoutEffect means no automatic scroll-to-bottom
|
||||
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT scroll when re-rendered (e.g., during resize)", () => {
|
||||
const { rerender } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
mock.state.scrollHeight = 1500;
|
||||
rerender();
|
||||
|
||||
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll position tracking", () => {
|
||||
it("tracks hitBottom correctly via onChatBodyScroll", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// Position at bottom: scrollTop(480) + clientHeight(500) = 980 >= 1000 - 20
|
||||
mock.state.scrollTop = 480;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.hitBottom).toBe(true);
|
||||
|
||||
// Position not at bottom: scrollTop(200) + clientHeight(500) = 700 < 980
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.hitBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("disables autoScroll when user scrolls up", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// First scroll to establish prevScrollTopRef
|
||||
mock.state.scrollTop = 400;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
|
||||
// Scroll up (lower scrollTop than previous)
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(false);
|
||||
});
|
||||
|
||||
it("re-enables autoScroll when user reaches bottom", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// Scroll up to disable autoScroll
|
||||
mock.state.scrollTop = 400;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(false);
|
||||
|
||||
// Scroll to bottom
|
||||
mock.state.scrollTop = 500; // 500 + 500 = 1000 >= 980
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { useFilteredEvents } from "#/hooks/use-filtered-events";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
@@ -27,28 +27,13 @@ import { ChatMessagesSkeleton } from "./chat-messages-skeleton";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { Messages as V1Messages } from "#/components/v1/chat";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import {
|
||||
isV0Event,
|
||||
isV1Event,
|
||||
isSystemPromptEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
@@ -73,8 +58,16 @@ export function ChatInterface() {
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const {
|
||||
v0Events,
|
||||
v1UiEvents,
|
||||
v1FullEvents,
|
||||
totalEvents,
|
||||
hasSubstantiveAgentActions,
|
||||
v0UserEventsExist,
|
||||
v1UserEventsExist,
|
||||
userEventsExist,
|
||||
} = useFilteredEvents();
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -140,74 +133,21 @@ export function ChatInterface() {
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Track when we should show V1 messages (after DOM has rendered)
|
||||
const [showV1Messages, setShowV1Messages] = React.useState(false);
|
||||
const prevV1LoadingRef = React.useRef(
|
||||
conversationWebSocket?.isLoadingHistory,
|
||||
);
|
||||
|
||||
// Wait for DOM to render before showing V1 messages
|
||||
React.useEffect(() => {
|
||||
const wasLoading = prevV1LoadingRef.current;
|
||||
const isLoading = conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
if (wasLoading && !isLoading) {
|
||||
// Loading just finished - wait for next frame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
setShowV1Messages(true);
|
||||
});
|
||||
} else if (isLoading) {
|
||||
// Reset when loading starts
|
||||
setShowV1Messages(false);
|
||||
}
|
||||
|
||||
prevV1LoadingRef.current = isLoading;
|
||||
}, [conversationWebSocket?.isLoadingHistory]);
|
||||
// Show V1 messages immediately if events exist in store (e.g., remount),
|
||||
// or once loading completes. This replaces the old transition-observation
|
||||
// pattern (useState + useEffect watching loading→loaded) which always showed
|
||||
// skeleton on remount because local state initialized to false.
|
||||
const showV1Messages =
|
||||
v1FullEvents.length > 0 || !conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
const isReturningToConversation = !!params.conversationId;
|
||||
// Only show loading skeleton when genuinely loading AND no events in store yet.
|
||||
// If events exist (e.g., remount after data was already fetched), skip skeleton.
|
||||
const isHistoryLoading =
|
||||
(isLoadingMessages && !isV1Conversation) ||
|
||||
(isV1Conversation &&
|
||||
(conversationWebSocket?.isLoadingHistory || !showV1Messages));
|
||||
(isLoadingMessages && !isV1Conversation && v0Events.length === 0) ||
|
||||
(isV1Conversation && !showV1Messages);
|
||||
const isChatLoading = isHistoryLoading && !isTask;
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
storeEvents
|
||||
.filter(isV1Event)
|
||||
.some(
|
||||
(event) =>
|
||||
event.source === "agent" &&
|
||||
!isSystemPromptEvent(event) &&
|
||||
!isConversationStateUpdateEvent(event),
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
const handleSendMessage = async (
|
||||
content: string,
|
||||
originalImages: File[],
|
||||
@@ -280,10 +220,6 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
// Get server status indicator props
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@@ -337,7 +273,7 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
{(!isLoadingMessages || v0Events.length > 0) && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
|
||||
@@ -1,15 +1,113 @@
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { MobileLayout } from "./mobile-layout";
|
||||
import { DesktopLayout } from "./desktop-layout";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { ResizeHandle } from "../../../ui/resize-handle";
|
||||
import { useResizablePanels } from "#/hooks/use-resizable-panels";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
|
||||
function getMobileChatPanelClass(isRightPanelShown: boolean) {
|
||||
return isRightPanelShown ? "h-160" : "flex-1";
|
||||
}
|
||||
|
||||
function getDesktopTabPanelClass(isRightPanelShown: boolean) {
|
||||
return isRightPanelShown
|
||||
? "translate-x-0 opacity-100"
|
||||
: "w-0 translate-x-full opacity-0";
|
||||
}
|
||||
|
||||
export function ConversationMain() {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = useBreakpoint();
|
||||
const { isRightPanelShown } = useConversationStore();
|
||||
|
||||
if (width && width <= 1024) {
|
||||
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
|
||||
}
|
||||
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
|
||||
useResizablePanels({
|
||||
defaultLeftWidth: 50,
|
||||
minLeftWidth: 30,
|
||||
maxLeftWidth: 80,
|
||||
storageKey: "desktop-layout-panel-width",
|
||||
});
|
||||
|
||||
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "relative flex-1 flex flex-col"
|
||||
: "h-full flex flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"flex flex-1 overflow-hidden",
|
||||
isMobile ? "flex-col" : "transition-all duration-300 ease-in-out",
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? { transitionProperty: isDragging ? "none" : "all" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Chat Panel - always mounted, styled differently for mobile/desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-base overflow-hidden",
|
||||
isMobile
|
||||
? getMobileChatPanelClass(isRightPanelShown)
|
||||
: "transition-all duration-300 ease-in-out",
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
width: isRightPanelShown ? `${leftWidth}%` : "100%",
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ChatInterfaceWrapper
|
||||
isRightPanelShown={!isMobile && isRightPanelShown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle - only shown on desktop when right panel is visible */}
|
||||
{!isMobile && isRightPanelShown && (
|
||||
<ResizeHandle onMouseDown={handleMouseDown} />
|
||||
)}
|
||||
|
||||
{/* Tab Content Panel - always mounted, styled as bottom sheet (mobile) or side panel (desktop) */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isMobile
|
||||
? cn(
|
||||
"absolute bottom-4 left-0 right-0 top-160",
|
||||
isRightPanelShown
|
||||
? "h-160 translate-y-0 opacity-100"
|
||||
: "h-0 translate-y-full opacity-0",
|
||||
)
|
||||
: getDesktopTabPanelClass(isRightPanelShown),
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
width: isRightPanelShown ? `${rightWidth}%` : "0%",
|
||||
transitionProperty: isDragging ? "opacity, transform" : "all",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2"
|
||||
: "flex flex-col flex-1 gap-3 min-w-max h-full",
|
||||
)}
|
||||
>
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { ResizeHandle } from "../../../ui/resize-handle";
|
||||
import { useResizablePanels } from "#/hooks/use-resizable-panels";
|
||||
|
||||
interface DesktopLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
|
||||
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
|
||||
useResizablePanels({
|
||||
defaultLeftWidth: 50,
|
||||
minLeftWidth: 30,
|
||||
maxLeftWidth: 80,
|
||||
storageKey: "desktop-layout-panel-width",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-1 transition-all duration-300 ease-in-out overflow-hidden"
|
||||
style={{
|
||||
// Only apply smooth transitions when not dragging
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}}
|
||||
>
|
||||
{/* Left Panel (Chat) */}
|
||||
<div
|
||||
className="flex flex-col bg-base overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: isRightPanelShown ? `${leftWidth}%` : "100%",
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}}
|
||||
>
|
||||
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
{isRightPanelShown && <ResizeHandle onMouseDown={handleMouseDown} />}
|
||||
|
||||
{/* Right Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isRightPanelShown
|
||||
? "translate-x-0 opacity-100"
|
||||
: "w-0 translate-x-full opacity-0",
|
||||
)}
|
||||
style={{
|
||||
width: isRightPanelShown ? `${rightWidth}%` : "0%",
|
||||
transitionProperty: isDragging ? "opacity, transform" : "all",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-3 min-w-max h-full">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ChatInterface } from "../../chat/chat-interface";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MobileLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col">
|
||||
{/* Chat area - shrinks when panel slides up */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-base overflow-hidden",
|
||||
isRightPanelShown ? "h-160" : "flex-1",
|
||||
)}
|
||||
>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
|
||||
{/* Bottom panel - slides up from bottom */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 right-0 top-160 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isRightPanelShown
|
||||
? "h-160 translate-y-0 opacity-100"
|
||||
: "h-0 translate-y-full opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
@@ -60,7 +60,7 @@ export function ConversationNameContextMenu({
|
||||
shareUrl,
|
||||
position = "bottom",
|
||||
}: ConversationNameContextMenuProps) {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = useBreakpoint();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
@@ -81,8 +81,6 @@ export function ConversationNameContextMenu({
|
||||
const hasInfo = Boolean(onDisplayCost);
|
||||
const hasControl = Boolean(onStop || onDelete);
|
||||
|
||||
const isMobile = width && width <= 1024;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
|
||||
import { MicroagentManagementMain } from "./microagent-management-main";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
|
||||
// Handle error events
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
@@ -92,8 +93,7 @@ const getUpdateConversationInstructions = (
|
||||
`;
|
||||
|
||||
export function MicroagentManagementContent() {
|
||||
// Responsive width state
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const isMobile = useBreakpoint();
|
||||
|
||||
const {
|
||||
addMicroagentModalVisible,
|
||||
@@ -112,17 +112,6 @@ export function MicroagentManagementContent() {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
|
||||
function handleResize() {
|
||||
setWidth(window.innerWidth);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideUpsertMicroagentModal = (isUpdate: boolean = false) => {
|
||||
if (isUpdate) {
|
||||
setUpdateMicroagentModalVisible(false);
|
||||
@@ -318,7 +307,7 @@ export function MicroagentManagementContent() {
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
if (width < 1024) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6">
|
||||
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
|
||||
|
||||
@@ -4,19 +4,21 @@ import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
|
||||
export const useConversationHistory = (conversationId?: string) => {
|
||||
const { data: conversation } = useUserConversation(conversationId ?? null);
|
||||
const conversationVersion = conversation?.conversation_version;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation-history", conversationId, conversation],
|
||||
queryKey: ["conversation-history", conversationId, conversationVersion],
|
||||
enabled: !!conversationId && !!conversation,
|
||||
queryFn: async () => {
|
||||
if (!conversationId || !conversation) return [];
|
||||
if (!conversationId || !conversationVersion) return [];
|
||||
|
||||
if (conversation.conversation_version === "V1") {
|
||||
if (conversationVersion === "V1") {
|
||||
return EventService.searchEventsV1(conversationId);
|
||||
}
|
||||
|
||||
return EventService.searchEventsV0(conversationId);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes — survive navigation away and back (AC5)
|
||||
});
|
||||
};
|
||||
|
||||
33
frontend/src/hooks/use-breakpoint.ts
Normal file
33
frontend/src/hooks/use-breakpoint.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 1024;
|
||||
|
||||
/**
|
||||
* Returns true when window width is at or below the breakpoint.
|
||||
* Only triggers a re-render when the boolean value changes (i.e., when the
|
||||
* width crosses the breakpoint), NOT on every pixel of resize.
|
||||
*
|
||||
* This replaces useWindowSize() for breakpoint detection, avoiding
|
||||
* unnecessary re-renders during drag resize.
|
||||
*/
|
||||
export function useBreakpoint(breakpoint: number = MOBILE_BREAKPOINT): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => window.innerWidth <= breakpoint,
|
||||
);
|
||||
const isMobileRef = useRef(isMobile);
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
const newIsMobile = window.innerWidth <= breakpoint;
|
||||
if (newIsMobile !== isMobileRef.current) {
|
||||
isMobileRef.current = newIsMobile;
|
||||
setIsMobile(newIsMobile);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
96
frontend/src/hooks/use-filtered-events.ts
Normal file
96
frontend/src/hooks/use-filtered-events.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import {
|
||||
shouldRenderEvent,
|
||||
hasUserEvent,
|
||||
} from "#/components/features/chat/event-content-helpers/should-render-event";
|
||||
import {
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
} from "#/components/v1/chat";
|
||||
import {
|
||||
isV0Event,
|
||||
isV1Event,
|
||||
isSystemPromptEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
/**
|
||||
* Hook that provides memoized filtered event arrays for ChatInterface.
|
||||
*
|
||||
* Why: Event filtering (V0, V1 UI, V1 full) was previously computed on every
|
||||
* render without useMemo. This caused unnecessary recomputation whenever
|
||||
* ChatInterface re-rendered for any reason (e.g., agent state change, scroll,
|
||||
* typing). By memoizing with proper dependencies, the filtered arrays maintain
|
||||
* referential stability when the underlying store data hasn't changed, which
|
||||
* prevents unnecessary downstream re-renders of Messages components.
|
||||
*/
|
||||
export function useFilteredEvents() {
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = React.useMemo(
|
||||
() =>
|
||||
storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = React.useMemo(
|
||||
() => uiEvents.filter(isV1Event).filter(shouldRenderV1Event),
|
||||
[uiEvents],
|
||||
);
|
||||
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = React.useMemo(
|
||||
() => storeEvents.filter(isV1Event),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = React.useMemo(
|
||||
() => v0Events.length || v1UiEvents.length,
|
||||
[v0Events, v1UiEvents],
|
||||
);
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
// Reuses memoized v0Events and v1FullEvents to avoid redundant filtering
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
v0Events.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
v1FullEvents.some(
|
||||
(event) =>
|
||||
event.source === "agent" &&
|
||||
!isSystemPromptEvent(event) &&
|
||||
!isConversationStateUpdateEvent(event),
|
||||
),
|
||||
[v0Events, v1FullEvents],
|
||||
);
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return {
|
||||
storeEvents,
|
||||
uiEvents,
|
||||
v0Events,
|
||||
v1UiEvents,
|
||||
v1FullEvents,
|
||||
totalEvents,
|
||||
hasSubstantiveAgentActions,
|
||||
v0UserEventsExist,
|
||||
v1UserEventsExist,
|
||||
userEventsExist,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import {
|
||||
RefObject,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import { RefObject, useState, useCallback, useRef } from "react";
|
||||
|
||||
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
// Track whether we should auto-scroll to the bottom when content changes
|
||||
// Track whether the user is currently near the bottom of the scroll area.
|
||||
// Used by consumers (e.g., likert-scale) to decide whether to scroll when
|
||||
// new UI elements appear. NOT used for automatic content-following.
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
|
||||
// Track whether the user is currently at the bottom of the scroll area
|
||||
@@ -52,12 +48,11 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
[isAtBottom],
|
||||
);
|
||||
|
||||
// Scroll to bottom function with animation
|
||||
// Scroll to bottom on manual click only
|
||||
const scrollDomToBottom = useCallback(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
// Set autoscroll to true when manually scrolling to bottom
|
||||
setAutoscroll(true);
|
||||
setHitBottom(true);
|
||||
|
||||
@@ -66,18 +61,6 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
|
||||
}
|
||||
}, [scrollRef]);
|
||||
|
||||
// Auto-scroll effect that runs when content changes
|
||||
// Use useLayoutEffect to scroll after DOM updates but before paint
|
||||
useLayoutEffect(() => {
|
||||
// Only auto-scroll if autoscroll is enabled
|
||||
if (autoscroll) {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
dom.scrollTop = dom.scrollHeight;
|
||||
}
|
||||
}
|
||||
}); // No dependency array - runs after every render to follow new content
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll: autoscroll,
|
||||
|
||||
Reference in New Issue
Block a user