Decouple core components from react-router (#48)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-04-30 06:51:07 -04:00
committed by GitHub
co-authored by openhands
parent 82f27cc661
commit 254e2d7edd
48 changed files with 527 additions and 238 deletions
+2
View File
@@ -81,4 +81,6 @@
- Settings selectors no longer use the v2 `Autocomplete` APIs. `settings-dropdown-input.tsx` and `model-selector.tsx` now use HeroUI v3 `ComboBox` + `ListBox` patterns, and the model/provider comboboxes must keep their `name` attributes so `FormData` submission still populates settings diffs.
- Tooltip consumers that used the old monolithic `Tooltip` props should prefer the shared `StyledTooltip` wrapper so HeroUI v3 trigger/content typing stays centralized.
- Visual verification artifacts for the migration live under `.pr/issue-44/`; to reproduce the static screenshots, use `npm run build:mock`, serve `build/` (for example with `python -m http.server --directory build`), and navigate client-side before capturing screenshots because a plain static server will not provide SPA deep-link fallbacks.
- Route decoupling note: `src/components/` should stay free of direct `react-router` imports. Route state now flows through `src/context/navigation-context.tsx`, the standalone app bridges router state with `src/routes/react-router-navigation-provider.tsx`, and link-like UI should use `src/components/shared/navigation-link.tsx`.
- Test helper note: `test-utils.tsx` now wraps renders with a default `NavigationProvider` (`currentPath: "/"`, `conversationId: "test-conversation-id"`). Navigation-sensitive tests can override that via `renderWithProviders(..., { navigation: { ... } })`.
+3 -7
View File
@@ -3,13 +3,9 @@ import { screen, render } from "@testing-library/react";
import React from "react";
// Mock modules before importing the component
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...(actual as object),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
vi.mock("#/context/conversation-context", () => ({
useConversation: () => ({ conversationId: "test-conversation-id" }),
@@ -16,7 +16,10 @@ import { renderWithProviders, useParamsMock } from "test-utils";
import type { Message } from "#/message";
import { SUGGESTIONS } from "#/utils/suggestions";
import { ChatInterface } from "#/components/features/chat/chat-interface";
import { useConversationId } from "#/hooks/use-conversation-id";
import {
useConversationId,
useOptionalConversationId,
} from "#/hooks/use-conversation-id";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useConfig } from "#/hooks/query/use-config";
@@ -31,7 +34,10 @@ 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/use-conversation-id", () => ({
useConversationId: vi.fn(),
useOptionalConversationId: vi.fn(),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
@@ -94,6 +100,9 @@ beforeEach(() => {
vi.mocked(useConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
vi.mocked(useOptionalConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
});
describe("ChatInterface - Chat Suggestions", () => {
@@ -6,7 +6,10 @@ 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 { useConversationId } from "#/hooks/use-conversation-id";
import {
useConversationId,
useOptionalConversationId,
} 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";
@@ -22,7 +25,10 @@ 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/use-conversation-id", () => ({
useConversationId: vi.fn(),
useOptionalConversationId: vi.fn(),
}));
vi.mock("#/hooks/query/use-active-conversation");
vi.mock("#/contexts/conversation-websocket-context");
@@ -113,6 +119,9 @@ describe("ChatInterface – message display continuity (spec 3.1)", () => {
vi.mocked(useConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
vi.mocked(useOptionalConversationId).mockReturnValue({
conversationId: "test-conversation-id",
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { app_mode: "local" },
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "test-utils";
import { ContextMenuNavLink } from "#/components/features/context-menu/context-menu-nav-link";
import { I18nKey } from "#/i18n/declaration";
@@ -12,11 +12,7 @@ const mockNavItem = {
};
const renderContextMenuNavLink = (item = mockNavItem, onClick = vi.fn()) =>
render(
<MemoryRouter>
<ContextMenuNavLink item={item} onClick={onClick} />
</MemoryRouter>,
);
renderWithProviders(<ContextMenuNavLink item={item} onClick={onClick} />);
describe("ContextMenuNavLink", () => {
it("should render the link with icon and text", () => {
@@ -12,7 +12,6 @@ import {
import { renderWithProviders } from "test-utils";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
import { BrowserRouter } from "react-router";
import type { Conversation } from "#/api/open-hands.types";
// Hoisted mocks for controllable return values
@@ -88,13 +87,14 @@ vi.mock("react-i18next", async () => {
};
});
// Helper function to render ConversationName with Router context
// Helper function to render ConversationName with navigation context
const renderConversationNameWithRouter = () => {
return renderWithProviders(
<BrowserRouter>
<ConversationName />
</BrowserRouter>,
);
return renderWithProviders(<ConversationName />, {
navigation: {
currentPath: "/conversations/test-conversation-id",
conversationId: "test-conversation-id",
},
});
};
describe("ConversationName", () => {
@@ -1,8 +1,7 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
@@ -41,29 +40,14 @@ vi.mock("react-i18next", async () => {
};
});
const renderNewConversation = () => {
const RouterStub = createRoutesStub([
{
Component: NewConversation,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
const renderNewConversation = (navigate = vi.fn()) =>
renderWithProviders(<NewConversation />, {
navigation: { navigate },
});
};
describe("NewConversation", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
it("should create an empty conversation and navigate when pressing the launch from scratch button", async () => {
const navigate = vi.fn();
const createConversationSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue({
@@ -93,15 +77,15 @@ describe("NewConversation", () => {
updated_at: new Date().toISOString(),
});
renderNewConversation();
renderNewConversation(navigate);
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledOnce();
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith("/conversations/conv-123");
});
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
@@ -1,7 +1,6 @@
import { screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { BrowserRouter } from "react-router";
import { RecentConversation } from "#/components/features/home/recent-conversations/recent-conversation";
import type { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { V1ExecutionStatus } from "#/types/v1/core";
@@ -47,11 +46,7 @@ const baseConversation: V1AppConversation = {
};
const renderRecentConversation = (conversation: V1AppConversation) =>
renderWithProviders(
<BrowserRouter>
<RecentConversation conversation={conversation} />
</BrowserRouter>,
);
renderWithProviders(<RecentConversation conversation={conversation} />);
describe("RecentConversation - llm_model", () => {
it("should render the llm model when provided", () => {
@@ -1,8 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { renderWithProviders } from "test-utils";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import { TaskCard } from "#/components/features/home/tasks/task-card";
@@ -39,26 +38,10 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
{ id: "5", full_name: "repo5", git_provider: "azure_devops", is_public: true },
];
const renderTaskCard = (task = MOCK_TASK_1) => {
const RouterStub = createRoutesStub([
{
Component: () => <TaskCard task={task} />,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
const renderTaskCard = (task = MOCK_TASK_1, navigate = vi.fn()) =>
renderWithProviders(<TaskCard task={task} />, {
navigation: { navigate },
});
};
describe("TaskCard", () => {
it("format the issue id", async () => {
@@ -177,6 +160,8 @@ describe("TaskCard", () => {
});
it("should navigate to the conversation page after creating a conversation", async () => {
const navigate = vi.fn();
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue({
id: "task-id",
created_by_user_id: null,
@@ -204,12 +189,15 @@ describe("TaskCard", () => {
updated_at: new Date().toISOString(),
});
renderTaskCard();
renderTaskCard(MOCK_TASK_1, navigate);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
// Wait for navigation to the conversation page
await screen.findByTestId("conversation-screen");
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith(
"/conversations/test-conversation-id",
);
});
});
});
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "test-utils";
import { SettingsNavLink } from "#/components/features/settings/settings-nav-link";
import { I18nKey } from "#/i18n/declaration";
@@ -14,13 +14,11 @@ const mockNavItem = {
const renderSettingsNavLink = (
item = mockNavItem,
onClick = vi.fn(),
initialPath = "/",
currentPath = "/",
) =>
render(
<MemoryRouter initialEntries={[initialPath]}>
<SettingsNavLink item={item} onClick={onClick} />
</MemoryRouter>,
);
renderWithProviders(<SettingsNavLink item={item} onClick={onClick} />, {
navigation: { currentPath },
});
describe("SettingsNavLink", () => {
it("should render the link with icon and text", () => {
@@ -55,6 +53,12 @@ describe("SettingsNavLink", () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should mark the current settings route as active", () => {
renderSettingsNavLink(mockNavItem, vi.fn(), "/settings/test");
expect(screen.getByRole("link")).toHaveAttribute("aria-current", "page");
});
it("should render different text based on item prop", () => {
// Arrange
const customItem = {
@@ -1,8 +1,7 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
@@ -12,21 +11,20 @@ describe("SettingsForm", () => {
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const RouteStub = createRoutesStub([
{
Component: () => (
<SettingsForm settings={DEFAULT_SETTINGS} onClose={onCloseMock} />
),
path: "/",
},
]);
beforeEach(() => {
vi.clearAllMocks();
});
it("should save the user settings and close the modal when the form is submitted", async () => {
it("should save the user settings and close the modal when submitted outside a conversation route", async () => {
const user = userEvent.setup();
renderWithProviders(<RouteStub />);
renderWithProviders(
<SettingsForm settings={DEFAULT_SETTINGS} onClose={onCloseMock} />,
{
navigation: { currentPath: "/settings" },
},
);
const saveButton = screen.getByRole("button", { name: /save/i });
await user.click(saveButton);
await user.click(screen.getByTestId("save-settings-button"));
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -37,5 +35,40 @@ describe("SettingsForm", () => {
}),
}),
);
expect(onCloseMock).toHaveBeenCalled();
});
it("should confirm before saving when submitted from a conversation route", async () => {
const user = userEvent.setup();
renderWithProviders(
<SettingsForm settings={DEFAULT_SETTINGS} onClose={onCloseMock} />,
{
navigation: {
currentPath: "/conversations/test-conversation-id",
conversationId: "test-conversation-id",
},
},
);
await user.click(screen.getByTestId("save-settings-button"));
expect(saveSettingsSpy).not.toHaveBeenCalled();
const confirmButton = screen.getByRole("button", {
name: /BUTTON\$END_SESSION|end session/i,
});
expect(confirmButton).toBeInTheDocument();
await user.click(confirmButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
agent_settings_diff: expect.objectContaining({
llm: expect.objectContaining({
model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"),
}),
}),
}),
);
expect(onCloseMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
NavigationProvider,
type NavigationContextValue,
} from "#/context/navigation-context";
import { NavigationLink } from "#/components/shared/navigation-link";
function renderNavigationLink(
currentPath = "/",
overrides: Partial<NavigationContextValue> = {},
) {
const value: NavigationContextValue = {
currentPath,
conversationId: null,
isNavigating: false,
navigate: vi.fn(),
...overrides,
};
const result = render(
<NavigationProvider value={value}>
<NavigationLink to="/settings/integrations">Integrations</NavigationLink>
</NavigationProvider>,
);
return {
...result,
navigate: value.navigate,
};
}
describe("NavigationLink", () => {
it("renders the destination href and active state from navigation context", () => {
renderNavigationLink("/settings/integrations");
expect(screen.getByRole("link", { name: "Integrations" })).toHaveAttribute(
"href",
"/settings/integrations",
);
expect(screen.getByRole("link", { name: "Integrations" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("uses the injected navigate callback on click", () => {
const { navigate } = renderNavigationLink();
const link = screen.getByRole("link", { name: "Integrations" });
fireEvent.click(link);
expect(navigate).toHaveBeenCalledWith("/settings/integrations", {
replace: false,
});
});
});
@@ -6,9 +6,13 @@ import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation
const mockNavigate = vi.fn();
vi.mock("react-router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ conversationId: "conv-123" }),
vi.mock("#/context/navigation-context", () => ({
useNavigation: () => ({
currentPath: "/conversations/conv-123",
conversationId: "conv-123",
isNavigating: false,
navigate: mockNavigate,
}),
}));
vi.mock("react-i18next", () => ({
@@ -1,9 +1,9 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
import { FaTriangleExclamation } from "react-icons/fa6";
import CloseIcon from "#/icons/close.svg?react";
import { useNavigation } from "#/context/navigation-context";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
@@ -27,7 +27,7 @@ export function AlertBanner({
null,
);
const { pathname } = useLocation();
const { currentPath } = useNavigation();
// Format ISO timestamp to user's local timezone
const formatMaintenanceTime = (isoTimeString: string): string => {
@@ -118,7 +118,7 @@ export function AlertBanner({
className={cn(
"bg-[#0D0F11] border border-primary text-white p-4 rounded",
"flex flex-row items-center justify-between m-1",
pathname === "/" && "mt-3 mr-3",
currentPath === "/" && "mt-3 mr-3",
)}
>
<div className="flex items-center">
@@ -1,6 +1,5 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { createChatMessage } from "#/services/chat-service";
@@ -34,6 +33,7 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont
import ChatStatusIndicator from "./chat-status-indicator";
import { getStatusColor, getStatusText } from "#/utils/utils";
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
import { I18nKey } from "#/i18n/declaration";
function getEntryPoint(
@@ -112,7 +112,7 @@ export function ChatInterface() {
}, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { conversationId } = useOptionalConversationId();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
@@ -124,7 +124,7 @@ export function ChatInterface() {
const showV1Messages =
v1FullEvents.length > 0 || !conversationWebSocket?.isLoadingHistory;
const isReturningToConversation = !!params.conversationId;
const isReturningToConversation = !!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 = !showV1Messages;
@@ -137,7 +137,7 @@ export function ChatInterface() {
) => {
// Handle /new command for V1 conversations
if (content.trim() === "/new") {
if (!params.conversationId) {
if (!conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_NO_ID));
return;
}
@@ -187,7 +187,7 @@ export function ChatInterface() {
const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
files.length > 0
? await uploadFiles({ conversationId: params.conversationId!, files })
? await uploadFiles({ conversationId: conversationId!, files })
: { skipped_files: [], uploaded_files: [] };
skippedFiles.forEach((f) => displayErrorToast(f.reason));
@@ -292,7 +292,7 @@ export function ChatInterface() {
</div>
<div className="flex flex-col gap-[6px]">
<BtwMessages conversationId={params.conversationId} />
<BtwMessages conversationId={conversationId} />
<div className="flex justify-between relative">
<div className="flex items-end gap-1">
<ConfirmationModeEnabled />
@@ -1,7 +1,7 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router";
import { X } from "lucide-react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
@@ -44,12 +44,12 @@ export function ErrorMessageBanner({
i18nKey={message}
components={{
a: (
<Link
<NavigationLink
className="underline font-bold cursor-pointer"
to="/settings/billing"
>
link
</Link>
</NavigationLink>
),
}}
/>
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -8,6 +7,7 @@ import ArrowUp from "#/icons/angle-up-solid.svg?react";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { NavigationLink } from "#/components/shared/navigation-link";
import { cn } from "#/utils/utils";
import { MarkdownRenderer } from "../markdown/markdown-renderer";
import { MonoComponent } from "./mono-component";
@@ -107,12 +107,12 @@ export function ExpandableMessage({
<div className="font-bold text-danger">
{t(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS)}
</div>
<Link
<NavigationLink
className="mt-2 mb-2 w-full h-10 rounded-sm flex items-center justify-center gap-2 bg-primary text-[#0D0F11]"
to="/settings/billing"
>
{t(I18nKey.BILLING$CLICK_TO_TOP_UP)}
</Link>
</NavigationLink>
</div>
</div>
);
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { GitControlBarRepoButton } from "./git-control-bar-repo-button";
import { GitControlBarBranchButton } from "./git-control-bar-branch-button";
import { GitControlBarPullButton } from "./git-control-bar-pull-button";
@@ -16,6 +15,7 @@ import { Branch, GitRepository } from "#/types/git";
import { I18nKey } from "#/i18n/declaration";
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
import { OpenRepositoryModal } from "./open-repository-modal";
import { useConversationId } from "#/hooks/use-conversation-id";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useHomeStore } from "#/stores/home-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
@@ -26,7 +26,7 @@ interface GitControlBarProps {
export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
const { t } = useTranslation();
const { conversationId } = useParams<{ conversationId: string }>();
const { conversationId } = useConversationId();
const [isOpenRepoModalOpen, setIsOpenRepoModalOpen] = useState(false);
const { addRecentRepository } = useHomeStore();
const { setOptimisticUserMessage } = useOptimisticUserMessageStore();
@@ -1,7 +1,7 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { NavigationLink } from "#/components/shared/navigation-link";
import { SettingsNavItem } from "#/constants/settings-nav";
interface ContextMenuNavLinkProps {
@@ -14,7 +14,7 @@ export function ContextMenuNavLink({ item, onClick }: ContextMenuNavLinkProps) {
const { to, icon, text } = item;
return (
<Link
<NavigationLink
to={to}
onClick={onClick}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
@@ -26,6 +26,6 @@ export function ContextMenuNavLink({ item, onClick }: ContextMenuNavLinkProps) {
size: 16, // For react-icons compatibility
} as React.SVGProps<SVGSVGElement>)}
{t(text as I18nKey)}
</Link>
</NavigationLink>
);
}
@@ -1,6 +1,6 @@
import { IoLockClosed } from "react-icons/io5";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { NavigationLink } from "#/components/shared/navigation-link";
import { I18nKey } from "#/i18n/declaration";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
@@ -16,13 +16,13 @@ export function SecurityLock() {
}
placement="top"
>
<Link
<NavigationLink
to="/settings"
className="mr-2 cursor-pointer hover:opacity-80 transition-all"
aria-label={t(I18nKey.SETTINGS$TITLE)}
>
<IoLockClosed size={20} />
</Link>
</NavigationLink>
</StyledTooltip>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import React from "react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useConversationId } from "#/hooks/use-conversation-id";
import ToolsIcon from "#/icons/tools.svg?react";
import { ToolsContextMenu } from "./tools-context-menu";
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
@@ -12,7 +12,7 @@ import { HooksModal } from "../conversation-panel/hooks-modal";
export function Tools() {
const { t } = useTranslation();
const { conversationId } = useParams<{ conversationId: string }>();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
@@ -1,5 +1,5 @@
import ReactDOM from "react-dom";
import { useLocation } from "react-router";
import { useNavigation } from "#/context/navigation-context";
import { cn } from "#/utils/utils";
interface ConversationPanelWrapperProps {
@@ -10,7 +10,7 @@ export function ConversationPanelWrapper({
isOpen,
children,
}: React.PropsWithChildren<ConversationPanelWrapperProps>) {
const { pathname } = useLocation();
const { currentPath } = useNavigation();
if (!isOpen) return null;
@@ -21,7 +21,7 @@ export function ConversationPanelWrapper({
<div
className={cn(
"absolute h-full w-full left-0 top-0 z-[100] bg-black/80 rounded-xl",
pathname === "/" && "bottom-0 top-0 md:top-3 md:bottom-3 h-auto",
currentPath === "/" && "bottom-0 top-0 md:top-3 md:bottom-3 h-auto",
)}
>
{children}
@@ -1,7 +1,7 @@
import React from "react";
import { NavLink, useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useNavigation } from "#/context/navigation-context";
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
import { useStartTasks } from "#/hooks/query/use-start-tasks";
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
@@ -10,6 +10,7 @@ import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { NavigationLink } from "#/components/shared/navigation-link";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
@@ -25,9 +26,8 @@ interface ConversationPanelProps {
export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const { conversationId: currentConversationId, navigate } = useNavigation();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const navigate = useNavigate();
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
@@ -162,17 +162,17 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
)}
{/* Render in-progress start tasks first */}
{startTasks?.map((task) => (
<NavLink
<NavigationLink
key={task.id}
to={`/conversations/task-${task.id}`}
onClick={onClose}
>
<StartTaskCard task={task} />
</NavLink>
</NavigationLink>
))}
{/* Then render completed conversations */}
{conversations?.map((conversation) => (
<NavLink
<NavigationLink
key={conversation.id}
to={`/conversations/${conversation.id}`}
onClick={onClose}
@@ -203,7 +203,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
llmModel={conversation.llm_model}
/>
</NavLink>
</NavigationLink>
))}
{/* Loading indicator for fetching more conversations */}
@@ -1,10 +1,10 @@
import React from "react";
import { useParams } from "react-router";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserProviders } from "#/hooks/use-user-providers";
import { getStatusColor } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
@@ -13,7 +13,7 @@ import { ServerStatusContextMenu } from "../controls/server-status-context-menu"
import { ConversationName } from "./conversation-name";
export function ConversationNameWithStatus() {
const { conversationId } = useParams<{ conversationId: string }>();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
const { isTask, taskStatus } = useTaskPolling();
@@ -1,8 +1,8 @@
import React from "react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
@@ -18,7 +18,7 @@ import CircuitIcon from "#/icons/u-circuit.svg?react";
export function ConversationName() {
const { t } = useTranslation();
const { conversationId } = useParams<{ conversationId: string }>();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const { mutate: updateConversation } = useUpdateConversation();
@@ -1,6 +1,6 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { NavigationLink } from "#/components/shared/navigation-link";
import { useSettings } from "#/hooks/query/use-settings";
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import { I18nKey } from "#/i18n/declaration";
@@ -20,7 +20,7 @@ export function ConnectToProviderMessage() {
</div>
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
</div>
<Link
<NavigationLink
data-testid="navigate-to-settings-button"
to="/settings/integrations"
className="self-start w-full"
@@ -34,7 +34,7 @@ export function ConnectToProviderMessage() {
{!isLoading && t("SETTINGS$TITLE")}
{isLoading && t("HOME$LOADING")}
</BrandButton>
</Link>
</NavigationLink>
</div>
);
}
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useNavigation } from "#/context/navigation-context";
import { BrandButton } from "../settings/brand-button";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
@@ -9,7 +9,7 @@ import PlusIcon from "#/icons/u-plus.svg?react";
export function NewConversation() {
const { t } = useTranslation();
const navigate = useNavigate();
const { navigate } = useNavigation();
const {
mutate: createConversation,
isPending,
@@ -1,12 +1,12 @@
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "../../settings/brand-button";
import { useNavigation } from "#/context/navigation-context";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
export function CreateConversationButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const { navigate } = useNavigation();
const {
mutate: createConversation,
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import CodeBranchIcon from "#/icons/u-code-branch.svg?react";
import { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { NavigationLink } from "#/components/shared/navigation-link";
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { Provider } from "#/types/settings";
import { formatTimeDelta } from "#/utils/format-time-delta";
@@ -21,7 +21,7 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
conversation.selected_repository && conversation.selected_branch;
return (
<Link
<NavigationLink
to={`/conversations/${conversation.id}`}
className="flex flex-col gap-1 p-[14px] cursor-pointer w-full rounded-lg hover:bg-[#5C5D62] transition-all duration-300 text-left"
>
@@ -86,6 +86,6 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
)}
</div>
</div>
</Link>
</NavigationLink>
);
}
@@ -1,8 +1,8 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
import { useNavigation } from "#/context/navigation-context";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
@@ -24,7 +24,7 @@ export function RepositorySelectionForm({
onRepoSelection,
isLoadingSettings = false,
}: RepositorySelectionFormProps) {
const navigate = useNavigate();
const { navigate } = useNavigation();
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { SuggestedTask } from "#/utils/types";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { TaskIssueNumber } from "./task-issue-number";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useNavigation } from "#/context/navigation-context";
import { cn } from "#/utils/utils";
const getTaskTypeMap = (
@@ -25,7 +25,7 @@ export function TaskCard({ task }: TaskCardProps) {
const { mutate: createConversation } = useCreateConversation();
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const navigate = useNavigate();
const { navigate } = useNavigation();
const handleLaunchConversation = () => {
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { TaskGroup } from "./task-group";
import { NavigationLink } from "#/components/shared/navigation-link";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn, getDisplayedTaskGroups, getTotalTaskCount } from "#/utils/utils";
@@ -85,14 +85,14 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
{t(I18nKey.TASKS$NO_GIT_PROVIDERS_DESCRIPTION)}
</Typography.Text>
<Link
<NavigationLink
to="/settings/integrations"
className="w-fit hover:underline"
>
<Typography.Text className="text-xs leading-4 text-[#FAFAFA] font-normal">
{t(I18nKey.TASKS$NO_GIT_PROVIDERS_CTA)}
</Typography.Text>
</Link>
</NavigationLink>
</div>
) : (
<Typography.Text className="text-xs leading-4 text-white font-medium px-[14px]">
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router";
import { NavigationLink } from "#/components/shared/navigation-link";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
@@ -15,7 +15,7 @@ export function SettingsNavLink({ item, onClick }: SettingsNavLinkProps) {
const { to, icon, text } = item;
return (
<NavLink
<NavigationLink
end
to={to}
onClick={onClick}
@@ -40,6 +40,6 @@ export function SettingsNavLink({ item, onClick }: SettingsNavLinkProps) {
{t(text as I18nKey)}
</Typography.Text>
</div>
</NavLink>
</NavigationLink>
);
}
+5 -5
View File
@@ -1,5 +1,4 @@
import React from "react";
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions";
@@ -14,12 +13,13 @@ import { ConversationPanelWrapper } from "../conversation-panel/conversation-pan
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useNavigation } from "#/context/navigation-context";
import { cn } from "#/utils/utils";
import { ENABLE_AUTOMATIONS } from "#/utils/feature-flags";
export function Sidebar() {
const { t } = useTranslation();
const { pathname } = useLocation();
const { currentPath } = useNavigation();
const user = useGitUser();
const { data: config } = useConfig();
const {
@@ -36,7 +36,7 @@ export function Sidebar() {
const settingsErrorStatus = getErrorStatus(settingsError);
React.useEffect(() => {
if (pathname === "/settings") {
if (currentPath === "/settings") {
setSettingsModalIsOpen(false);
} else if (
!isFetchingSettings &&
@@ -56,7 +56,7 @@ export function Sidebar() {
setSettingsModalIsOpen(true);
}
}, [
pathname,
currentPath,
isFetchingSettings,
settingsIsError,
settingsErrorStatus,
@@ -70,7 +70,7 @@ export function Sidebar() {
aria-label={t(I18nKey.SIDEBAR$NAVIGATION_LABEL)}
className={cn(
"h-[54px] p-3 md:p-0 md:h-[40px] md:h-auto flex flex-row md:flex-col gap-1 bg-base md:w-[75px] md:min-w-[75px] sm:pt-0 sm:px-2 md:pt-[14px] md:px-0",
pathname === "/" && "md:pt-6.5 md:pb-3",
currentPath === "/" && "md:pt-6.5 md:pb-3",
)}
>
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
@@ -1,6 +1,6 @@
import { NavLink } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { NavigationLink } from "#/components/shared/navigation-link";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import PlusIcon from "#/icons/u-plus.svg?react";
import { cn } from "#/utils/utils";
@@ -16,7 +16,7 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
return (
<StyledTooltip content={startNewProject} placement="right">
<NavLink
<NavigationLink
to="/"
data-testid="new-project-button"
aria-label={startNewProject}
@@ -31,7 +31,7 @@ export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
})}
>
<PlusIcon width={24} height={24} />
</NavLink>
</NavigationLink>
</StyledTooltip>
);
}
@@ -1,6 +1,6 @@
import { NavLink } from "react-router";
import { useTranslation } from "react-i18next";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { I18nKey } from "#/i18n/declaration";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
@@ -12,9 +12,9 @@ export function OpenHandsLogoButton() {
return (
<StyledTooltip content={tooltipText}>
<NavLink to="/" aria-label={ariaLabel}>
<NavigationLink to="/" aria-label={ariaLabel}>
<OpenHandsLogo width={46} height={30} />
</NavLink>
</NavigationLink>
</StyledTooltip>
);
}
@@ -1,7 +1,7 @@
import { useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import { usePostHog } from "posthog-js/react";
import { useNavigation } from "#/context/navigation-context";
import { I18nKey } from "#/i18n/declaration";
import { DangerModal } from "../confirmation-modals/danger-modal";
import { extractSettings } from "#/utils/settings-utils";
@@ -23,8 +23,7 @@ interface SettingsFormProps {
export function SettingsForm({ settings, onClose }: SettingsFormProps) {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
const location = useLocation();
const { currentPath } = useNavigation();
const { t } = useTranslation();
const formRef = React.useRef<HTMLFormElement>(null);
@@ -62,7 +61,7 @@ export function SettingsForm({ settings, onClose }: SettingsFormProps) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
if (location.pathname.startsWith("/conversations/")) {
if (currentPath.startsWith("/conversations/")) {
setConfirmEndSessionModalOpen(true);
} else {
handleFormSubmission(formData);
+93
View File
@@ -0,0 +1,93 @@
import React from "react";
import {
useNavigation,
type NavigationOptions,
} from "#/context/navigation-context";
interface NavigationLinkClassNameState {
isActive: boolean;
}
export interface NavigationLinkProps
extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"className" | "href"
> {
to: string;
replace?: boolean;
end?: boolean;
className?:
| string
| ((state: NavigationLinkClassNameState) => string | undefined);
}
function isModifiedEvent(event: React.MouseEvent<HTMLAnchorElement>) {
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
}
function isPathActive(currentPath: string, to: string, end: boolean) {
if (to === "/") {
return currentPath === to;
}
if (end) {
return currentPath === to;
}
return currentPath === to || currentPath.startsWith(`${to}/`);
}
export const NavigationLink = React.forwardRef<
HTMLAnchorElement,
NavigationLinkProps
>(function NavigationLink(
{
to,
replace = false,
end = false,
onClick,
className,
children,
target,
rel,
...props
},
ref,
) {
const { currentPath, navigate } = useNavigation();
const isActive = isPathActive(currentPath, to, end);
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
isModifiedEvent(event) ||
target === "_blank"
) {
return;
}
event.preventDefault();
navigate(to, { replace } satisfies NavigationOptions);
};
return (
<a
{...props}
ref={ref}
href={to}
target={target}
rel={rel}
onClick={handleClick}
className={resolvedClassName}
aria-current={isActive ? "page" : undefined}
>
{children}
</a>
);
});
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
export interface NavigationOptions {
replace?: boolean;
}
export interface NavigationContextValue {
currentPath: string;
conversationId: string | null;
isNavigating: boolean;
navigate: (to: string, options?: NavigationOptions) => void;
}
const noop = () => {};
const NavigationContext = React.createContext<NavigationContextValue>({
currentPath: "/",
conversationId: null,
isNavigating: false,
navigate: noop,
});
interface NavigationProviderProps {
value: NavigationContextValue;
children: React.ReactNode;
}
export function NavigationProvider({
value,
children,
}: NavigationProviderProps) {
return (
<NavigationContext.Provider value={value}>
{children}
</NavigationContext.Provider>
);
}
export function useNavigation() {
return React.useContext(NavigationContext);
}
@@ -1,5 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
@@ -9,11 +8,12 @@ import {
displaySuccessToast,
TOAST_OPTIONS,
} from "#/utils/custom-toast-handlers";
import { useNavigation } from "#/context/navigation-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
export const useNewConversationCommand = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { navigate } = useNavigation();
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
@@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import { useNavigation } from "#/context/navigation-context";
import { I18nKey } from "#/i18n/declaration";
import {
pauseV1ConversationSandbox,
@@ -19,8 +19,7 @@ import {
export const useUnifiedPauseConversationSandbox = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const params = useParams<{ conversationId: string }>();
const { conversationId: currentConversationId, navigate } = useNavigation();
return useMutation({
mutationKey: ["stop-conversation"],
@@ -66,7 +65,7 @@ export const useUnifiedPauseConversationSandbox = () => {
);
// Only redirect if we're stopping the conversation we're currently viewing
if (params.conversationId === variables.conversationId) {
if (currentConversationId === variables.conversationId) {
navigate("/");
}
},
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useNavigation } from "#/context/navigation-context";
import { useConversationId } from "#/hooks/use-conversation-id";
/**
@@ -21,7 +21,7 @@ import { useConversationId } from "#/hooks/use-conversation-id";
*/
export const useTaskPolling = () => {
const { conversationId } = useConversationId();
const navigate = useNavigate();
const { navigate } = useNavigation();
// Check if this is a task ID (format: "task-{uuid}")
const isTask = conversationId.startsWith("task-");
+8 -2
View File
@@ -1,7 +1,13 @@
import { useParams } from "react-router";
import { useNavigation } from "#/context/navigation-context";
export function useOptionalConversationId() {
const { conversationId } = useNavigation();
return { conversationId };
}
export function useConversationId() {
const { conversationId } = useParams<{ conversationId: string }>();
const { conversationId } = useOptionalConversationId();
if (!conversationId) {
throw new Error(
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import React from "react";
import { useParams, useNavigate } from "react-router";
import { useNavigation } from "#/context/navigation-context";
import useMetricsStore from "#/stores/metrics-store";
import { useDeleteConversation } from "./mutation/use-delete-conversation";
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
@@ -31,8 +31,7 @@ export function useConversationNameContextMenu({
onContextMenuToggle,
}: UseConversationNameContextMenuProps) {
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
const { conversationId: currentConversationId, navigate } = useNavigation();
const events = useEventStore((state) => state.events);
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
+2 -2
View File
@@ -1,5 +1,5 @@
import { useIsMutating } from "@tanstack/react-query";
import { useNavigation } from "react-router";
import { useNavigation } from "#/context/navigation-context";
export const useIsCreatingConversation = () => {
const navigation = useNavigation();
@@ -7,7 +7,7 @@ export const useIsCreatingConversation = () => {
mutationKey: ["create-conversation"],
});
const isNavigating = Boolean(navigation.location);
const isNavigating = navigation.isNavigating;
const hasPendingMutations = numberOfPendingMutations > 0;
return hasPendingMutations || isNavigating;
@@ -0,0 +1,49 @@
import React from "react";
import {
useLocation,
useMatches,
useNavigate as useReactRouterNavigate,
useNavigation as useReactRouterNavigation,
} from "react-router";
import {
NavigationProvider,
type NavigationContextValue,
} from "#/context/navigation-context";
interface MatchWithParams {
params?: {
conversationId?: string;
};
}
export function ReactRouterNavigationProvider({
children,
}: React.PropsWithChildren) {
const { pathname } = useLocation();
const navigate = useReactRouterNavigate();
const routerNavigation = useReactRouterNavigation();
const matches = useMatches() as MatchWithParams[];
const conversationId = React.useMemo(() => {
for (let index = matches.length - 1; index >= 0; index -= 1) {
const matchedConversationId = matches[index]?.params?.conversationId;
if (matchedConversationId) {
return matchedConversationId;
}
}
return null;
}, [matches]);
const value = React.useMemo<NavigationContextValue>(
() => ({
currentPath: pathname,
conversationId,
isNavigating: Boolean(routerNavigation.location),
navigate: (to, options) => navigate(to, options),
}),
[pathname, conversationId, routerNavigation.location, navigate],
);
return <NavigationProvider value={value}>{children}</NavigationProvider>;
}
+39 -36
View File
@@ -18,6 +18,7 @@ import { AlertBanner } from "#/components/features/alerts/alert-banner";
import { cn } from "#/utils/utils";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useAppTitle } from "#/hooks/use-app-title";
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
export function ErrorBoundary() {
const error = useRouteError();
@@ -90,44 +91,46 @@ export default function MainApp() {
}
return (
<div
data-testid="root-layout"
className={cn(
"h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden",
pathname === "/" ? "p-0" : "p-0 md:p-3 md:pl-0",
)}
>
<title>{appTitle}</title>
<Sidebar />
<ReactRouterNavigationProvider>
<div
data-testid="root-layout"
className={cn(
"h-screen lg:min-w-5xl flex flex-col md:flex-row bg-base overflow-hidden",
pathname === "/" ? "p-0" : "p-0 md:p-3 md:pl-0",
)}
>
<title>{appTitle}</title>
<Sidebar />
<div className="flex flex-col w-full h-[calc(100%-50px)] md:h-full gap-3">
{config.data &&
(config.data.maintenance_start_time ||
(config.data.faulty_models &&
config.data.faulty_models.length > 0) ||
config.data.error_message) && (
<AlertBanner
maintenanceStartTime={config.data.maintenance_start_time}
faultyModels={config.data.faulty_models}
errorMessage={config.data.error_message}
updatedAt={config.data.updated_at}
/>
)}
<div
id="root-outlet"
className="flex-1 relative overflow-auto custom-scrollbar"
>
<Outlet />
<div className="flex flex-col w-full h-[calc(100%-50px)] md:h-full gap-3">
{config.data &&
(config.data.maintenance_start_time ||
(config.data.faulty_models &&
config.data.faulty_models.length > 0) ||
config.data.error_message) && (
<AlertBanner
maintenanceStartTime={config.data.maintenance_start_time}
faultyModels={config.data.faulty_models}
errorMessage={config.data.error_message}
updatedAt={config.data.updated_at}
/>
)}
<div
id="root-outlet"
className="flex-1 relative overflow-auto custom-scrollbar"
>
<Outlet />
</div>
</div>
</div>
{consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => {
setConsentFormIsOpen(false);
}}
/>
)}
</div>
{consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => {
setConsentFormIsOpen(false);
}}
/>
)}
</div>
</ReactRouterNavigationProvider>
);
}
+26 -3
View File
@@ -11,6 +11,10 @@ import {
ObservationEvent,
PlanningFileEditorObservation,
} from "#/types/v1/core";
import {
NavigationProvider,
type NavigationContextValue,
} from "#/context/navigation-context";
import { SecurityRisk } from "#/types/v1/core";
export const useParamsMock = vi.fn(() => ({
@@ -49,8 +53,20 @@ i18n.use(initReactI18next).init({
},
});
const createNavigationValue = (
overrides: Partial<NavigationContextValue> = {},
): NavigationContextValue => ({
currentPath: "/",
conversationId: "test-conversation-id",
isNavigating: false,
navigate: vi.fn(),
...overrides,
});
// This type interface extends the default options for render from RTL
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {}
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
navigation?: Partial<NavigationContextValue>;
}
// Export our own customized renderWithProviders function that renders with QueryClient and i18next providers
// Since we're using Zustand stores, we don't need a Redux Provider wrapper
@@ -58,6 +74,9 @@ export function renderWithProviders(
ui: React.ReactElement,
renderOptions: ExtendedRenderOptions = {},
) {
const { navigation, ...rtlRenderOptions } = renderOptions;
const navigationValue = createNavigationValue(navigation);
function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider
@@ -67,11 +86,15 @@ export function renderWithProviders(
})
}
>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
<I18nextProvider i18n={i18n}>
<NavigationProvider value={navigationValue}>
{children}
</NavigationProvider>
</I18nextProvider>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
return render(ui, { wrapper: Wrapper, ...rtlRenderOptions });
}
export const createAxiosNotFoundErrorObject = () =>