fix(frontend): performance and loading state bugs (#12821)

This commit is contained in:
sp.wack
2026-02-11 19:34:52 +04:00
committed by GitHub
parent c55084e223
commit 85244499fe
16 changed files with 1516 additions and 242 deletions

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View 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);
});
});

View 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);
});
});
});

View 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);
});
});
});

View File

@@ -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={

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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]">

View File

@@ -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)
});
};

View 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;
}

View 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,
};
}

View File

@@ -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,