diff --git a/__tests__/components/features/conversation-panel/new-conversation-button.test.tsx b/__tests__/components/features/conversation-panel/new-conversation-button.test.tsx index 0d4120636..51766decb 100644 --- a/__tests__/components/features/conversation-panel/new-conversation-button.test.tsx +++ b/__tests__/components/features/conversation-panel/new-conversation-button.test.tsx @@ -141,9 +141,10 @@ describe("NewConversationButton", () => { path: "/workspace/project/repo1", }, ]); - vi.spyOn(AgentServerConversationService, "createConversation").mockImplementation( - () => new Promise(() => {}), - ); + vi.spyOn( + AgentServerConversationService, + "createConversation", + ).mockImplementation(() => new Promise(() => {})); const user = userEvent.setup(); renderWithProviders(); @@ -188,6 +189,75 @@ describe("NewConversationButton", () => { expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); }); + it("opens the conversation in a new tab when cmd/ctrl-clicking a workspace", async () => { + useWorkspacesStore.getState().addWorkspaces([ + { + id: "/workspace/project/repo1", + name: "repo1", + path: "/workspace/project/repo1", + }, + ]); + const navigate = vi.fn(); + vi.spyOn( + AgentServerConversationService, + "createConversation", + ).mockResolvedValue(makeStartTask("conv-cmd-1")); + + const newTab = { + location: { href: "" }, + close: vi.fn(), + } as unknown as Window; + const openSpy = vi.spyOn(window, "open").mockReturnValue(newTab); + + const user = userEvent.setup(); + renderWithProviders(, { + navigation: { navigate, currentPath: "/conversations" }, + }); + + await user.click(screen.getByTestId("new-conversation-button")); + await user.keyboard("{Meta>}"); + await user.click(screen.getByRole("button", { name: "repo1" })); + await user.keyboard("{/Meta}"); + + await waitFor(() => { + expect(openSpy).toHaveBeenCalledWith("about:blank", "_blank"); + }); + await waitFor(() => { + expect(newTab.location.href).toBe("/conversations/conv-cmd-1"); + }); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("closes the pre-opened tab when conversation creation fails on cmd/ctrl-click", async () => { + useWorkspacesStore.getState().addWorkspaces([ + { + id: "/workspace/project/repo1", + name: "repo1", + path: "/workspace/project/repo1", + }, + ]); + vi.spyOn( + AgentServerConversationService, + "createConversation", + ).mockRejectedValue(new Error("create failed")); + + const close = vi.fn(); + const newTab = { location: { href: "" }, close } as unknown as Window; + vi.spyOn(window, "open").mockReturnValue(newTab); + + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("new-conversation-button")); + await user.keyboard("{Meta>}"); + await user.click(screen.getByRole("button", { name: "repo1" })); + await user.keyboard("{/Meta}"); + + await waitFor(() => { + expect(close).toHaveBeenCalled(); + }); + }); + it("keeps the popover open when conversation creation fails", async () => { const navigate = vi.fn(); const createSpy = vi diff --git a/__tests__/components/features/home/new-conversation.test.tsx b/__tests__/components/features/home/new-conversation.test.tsx deleted file mode 100644 index b2d39bfd0..000000000 --- a/__tests__/components/features/home/new-conversation.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { NewConversation } from "#/components/features/home/new-conversation/new-conversation"; - -vi.mock("#/hooks/query/use-settings", async () => { - const actual = await vi.importActual( - "#/hooks/query/use-settings", - ); - return { - ...actual, - getSettingsQueryFn: vi.fn().mockResolvedValue({}), - }; -}); - -// Mock the translation function -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => { - // Return a mock translation for the test - const translations: Record = { - COMMON$START_FROM_SCRATCH: "Start from Scratch", - HOME$NEW_PROJECT_DESCRIPTION: "Create a new project from scratch", - COMMON$NEW_CONVERSATION: "New Conversation", - HOME$LOADING: "Loading...", - }; - return translations[key] || key; - }, - i18n: { language: "en" }, - }), - }; -}); - -const renderNewConversation = (navigate = vi.fn()) => - renderWithProviders(, { - navigation: { navigate }, - }); - -describe("NewConversation", () => { - it("should create an empty conversation and navigate when pressing the launch from scratch button", async () => { - const navigate = vi.fn(); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-123", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - renderNewConversation(navigate); - - const launchButton = screen.getByTestId("launch-new-conversation-button"); - await userEvent.click(launchButton); - - expect(createConversationSpy).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conv-123"); - }); - }); - - it("should change the launch button text to 'Loading...' when creating a conversation", async () => { - // Mock V1 API to never resolve, keeping the mutation in loading state - vi.spyOn(AgentServerConversationService, "createConversation").mockImplementation( - () => new Promise(() => {}), - ); - - renderNewConversation(); - - const launchButton = screen.getByTestId("launch-new-conversation-button"); - await userEvent.click(launchButton); - - expect(launchButton).toHaveTextContent(/Loading.../i); - expect(launchButton).toBeDisabled(); - }); -}); diff --git a/__tests__/components/features/home/repo-connector.test.tsx b/__tests__/components/features/home/repo-connector.test.tsx deleted file mode 100644 index 356818b41..000000000 --- a/__tests__/components/features/home/repo-connector.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { RepoConnector } from "#/components/features/home/repo-connector"; - -const mockUseUserProviders = vi.fn(); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => mockUseUserProviders(), -})); - -vi.mock("#/components/features/home/workspace-selection-form", () => ({ - WorkspaceSelectionForm: () =>
, -})); - -describe("RepoConnector", () => { - beforeEach(() => { - mockUseUserProviders.mockReturnValue({ - isLoadingSettings: false, - providers: ["github"], - }); - }); - - it("always shows the workspace launcher for the agent-server build", () => { - render(); - - expect(screen.getByTestId("stub-workspace-form")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/home/workspace-selection-form.test.tsx b/__tests__/components/features/home/workspace-selection-form.test.tsx deleted file mode 100644 index d1334b491..000000000 --- a/__tests__/components/features/home/workspace-selection-form.test.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, vi, beforeEach, it } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { WorkspaceSelectionForm } from "../../../../src/components/features/home/workspace-selection-form"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import FilesService from "#/api/files-service/files-service.api"; -import { useWorkspacesStore } from "#/stores/workspaces-store"; -import { LocalWorkspace } from "#/types/workspace"; - -const mockNavigate = vi.fn(); -const mockUseIsCreatingConversation = vi.fn(); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ - currentPath: "/", - conversationId: null, - isNavigating: false, - navigate: mockNavigate, - }), -})); - -vi.mock("#/hooks/use-is-creating-conversation", () => ({ - useIsCreatingConversation: () => mockUseIsCreatingConversation(), -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackConversationCreated: vi.fn(), - trackLoginButtonClick: vi.fn(), - }), -})); - -mockUseIsCreatingConversation.mockReturnValue(false); - -function makeStartTask(overrides: Record = {}) { - return { - id: "conv-abc", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-abc", - agent_server_url: "http://agent-server.local", - request: { initial_message: undefined, plugins: null }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - ...overrides, - } as never; -} - -function renderForm( - initialWorkspaces: LocalWorkspace[] = [], - initialParents: { id: string; name: string; path: string }[] = [], -) { - useWorkspacesStore.setState({ - workspaces: initialWorkspaces, - workspaceParents: initialParents, - }); - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("WorkspaceSelectionForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - mockUseIsCreatingConversation.mockReturnValue(false); - useWorkspacesStore.setState({ workspaces: [], workspaceParents: [] }); - }); - - it("Add Workspace adds only the chosen folder (not its subfolders) and dedupes on repeat", async () => { - vi.spyOn(FilesService, "getHome").mockResolvedValue({ home: "/Users/me" }); - const searchSpy = vi - .spyOn(FilesService, "searchSubdirs") - .mockImplementation(async (path: string) => { - if (path === "/Users/me") { - return { - items: [{ name: "dev", path: "/Users/me/dev" }], - next_page_id: null, - }; - } - if (path === "/Users/me/dev") { - return { - items: [ - { name: "repo1", path: "/Users/me/dev/repo1" }, - { name: "repo2", path: "/Users/me/dev/repo2" }, - { name: "repo3", path: "/Users/me/dev/repo3" }, - ], - next_page_id: null, - }; - } - throw new Error(`unexpected path ${path}`); - }); - - // Pre-seed one workspace to verify dedup - renderForm([{ id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }]); - const user = userEvent.setup(); - - // First pass: navigate into "dev" then click "Use this folder" - await user.click(screen.getByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - - await screen.findByTestId("folder-browser-modal"); - expect( - screen.queryByTestId("add-workspaces-button"), - ).not.toBeInTheDocument(); - - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await screen.findByTestId("folder-browser-entry-repo2"); - await user.click(screen.getByTestId("folder-browser-use")); - - await waitFor(() => - expect( - screen.queryByTestId("folder-browser-modal"), - ).not.toBeInTheDocument(), - ); - - // The same /Users/me/dev folder should be deduped, not duplicated, and - // its children should NOT have been imported as workspaces. - expect( - useWorkspacesStore - .getState() - .workspaces.map((w) => w.path) - .sort(), - ).toEqual(["/Users/me/dev"]); - - // Second pass: pick a child folder; it should be added as a single - // workspace (still no recursion into its subfolders). - await user.click(screen.getByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await user.click(await screen.findByTestId("folder-browser-entry-repo1")); - await user.click(screen.getByTestId("folder-browser-use")); - - await waitFor(() => - expect( - screen.queryByTestId("folder-browser-modal"), - ).not.toBeInTheDocument(), - ); - - expect( - useWorkspacesStore - .getState() - .workspaces.map((w) => w.path) - .sort(), - ).toEqual(["/Users/me/dev", "/Users/me/dev/repo1"]); - expect(searchSpy).toHaveBeenCalledWith("/Users/me/dev"); - }); - - it("Manage Workspaces lets you remove individual workspaces and clears the current selection", async () => { - const workspaces: LocalWorkspace[] = [ - { id: "/Users/me/dev/repo1", name: "repo1", path: "/Users/me/dev/repo1" }, - { id: "/Users/me/dev/repo2", name: "repo2", path: "/Users/me/dev/repo2" }, - ]; - renderForm(workspaces); - const user = userEvent.setup(); - const launchButton = screen.getByTestId("workspace-launch-button"); - - await user.click(screen.getByTestId("workspace-dropdown")); - const dropdownMenu = await screen.findByTestId("workspace-dropdown-menu"); - await user.click(within(dropdownMenu).getByText("repo1")); - expect(launchButton).toBeEnabled(); - - await user.click(screen.getByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("manage-workspaces-button")); - - await screen.findByTestId("manage-workspaces-modal"); - await user.click(screen.getByTestId("manage-workspaces-remove-repo1")); - - expect(useWorkspacesStore.getState().workspaces.map((w) => w.path)).toEqual( - ["/Users/me/dev/repo1", "/Users/me/dev/repo2"], - ); - - await screen.findByTestId("confirmation-modal"); - await user.click(screen.getByTestId("confirm-button")); - - expect(useWorkspacesStore.getState().workspaces.map((w) => w.path)).toEqual( - ["/Users/me/dev/repo2"], - ); - expect(launchButton).toBeDisabled(); - - await user.click(screen.getByTestId("manage-workspaces-done")); - await waitFor(() => - expect( - screen.queryByTestId("manage-workspaces-modal"), - ).not.toBeInTheDocument(), - ); - }); - - it("Manage Workspaces button is hidden when there are no workspaces", async () => { - renderForm([]); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("workspace-dropdown")); - await screen.findByTestId("add-workspaces-button"); - expect( - screen.queryByTestId("manage-workspaces-button"), - ).not.toBeInTheDocument(); - }); - - it("Launch creates a v1 conversation with the selected workspace path as working_dir", async () => { - const workspaces: LocalWorkspace[] = [ - { id: "/Users/me/dev/repo1", name: "repo1", path: "/Users/me/dev/repo1" }, - { id: "/Users/me/dev/repo2", name: "repo2", path: "/Users/me/dev/repo2" }, - ]; - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeStartTask({ app_conversation_id: "conv-xyz" })); - - renderForm(workspaces); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("workspace-dropdown")); - const items = await screen.findAllByText(/repo[12]/); - await user.click(items.find((el) => el.textContent === "repo2")!); - await user.click(screen.getByTestId("workspace-launch-button")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - null, - "/Users/me/dev/repo2", - undefined, - undefined, - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-xyz"), - ); - }); - - it("Launch button is disabled until a workspace is selected", async () => { - renderForm([ - { id: "/Users/me/dev/repo1", name: "repo1", path: "/Users/me/dev/repo1" }, - ]); - - const launchButton = screen.getByTestId("workspace-launch-button"); - expect(launchButton).toBeDisabled(); - - const user = userEvent.setup(); - await user.click(screen.getByTestId("workspace-dropdown")); - await user.click(await screen.findByText("repo1")); - - expect(launchButton).toBeEnabled(); - }); - - it("disables the workspace dropdown while parent workspaces are loading", async () => { - vi.spyOn(FilesService, "searchSubdirs").mockReturnValue( - new Promise(() => {}) as never, - ); - - renderForm( - [], - [{ id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }], - ); - - await waitFor(() => { - expect(screen.getByTestId("workspace-dropdown")).toBeDisabled(); - }); - expect(screen.getByTestId("workspace-status-message")).toBeInTheDocument(); - }); - - it("Add all subdirectories saves a workspace parent and lists its children dynamically", async () => { - vi.spyOn(FilesService, "getHome").mockResolvedValue({ home: "/Users/me" }); - const searchSpy = vi - .spyOn(FilesService, "searchSubdirs") - .mockImplementation(async (path: string) => { - if (path === "/Users/me") { - return { - items: [{ name: "dev", path: "/Users/me/dev" }], - next_page_id: null, - }; - } - if (path === "/Users/me/dev") { - return { - items: [ - { name: "repo1", path: "/Users/me/dev/repo1" }, - { name: "repo2", path: "/Users/me/dev/repo2" }, - ], - next_page_id: null, - }; - } - throw new Error(`unexpected path ${path}`); - }); - - renderForm(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - - await screen.findByTestId("folder-browser-modal"); - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await screen.findByTestId("folder-browser-entry-repo1"); - - // Click the "Add all subdirectories" button. - await user.click(screen.getByTestId("folder-browser-add-all-subdirs")); - - await waitFor(() => - expect( - screen.queryByTestId("folder-browser-modal"), - ).not.toBeInTheDocument(), - ); - - // The directory itself becomes a workspace parent, not a workspace. - expect(useWorkspacesStore.getState().workspaces).toEqual([]); - expect( - useWorkspacesStore.getState().workspaceParents.map((p) => p.path), - ).toEqual(["/Users/me/dev"]); - - // ...and its subdirectories surface as workspaces dynamically. - await user.click(screen.getByTestId("workspace-dropdown")); - const dynamicDropdown = await screen.findByTestId( - "workspace-dropdown-menu", - ); - await within(dynamicDropdown).findByText("repo1"); - await within(dynamicDropdown).findByText("repo2"); - - expect(searchSpy).toHaveBeenCalledWith("/Users/me/dev"); - }); - - it("Removing a workspace parent stops listing its children", async () => { - const searchSpy = vi - .spyOn(FilesService, "searchSubdirs") - .mockResolvedValue({ - items: [ - { name: "repoA", path: "/Users/me/dev/repoA" }, - { name: "repoB", path: "/Users/me/dev/repoB" }, - ], - next_page_id: null, - }); - - renderForm( - [], - [{ id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }], - ); - - const user = userEvent.setup(); - - // Children should appear in the dropdown. - await user.click(screen.getByTestId("workspace-dropdown")); - const dropdownMenu = await screen.findByTestId("workspace-dropdown-menu"); - await within(dropdownMenu).findByText("repoA"); - await within(dropdownMenu).findByText("repoB"); - - // Manage modal should expose a remove button for the parent. - await user.click(screen.getByTestId("manage-workspaces-button")); - await screen.findByTestId("manage-workspaces-modal"); - expect( - screen.getByTestId("manage-workspaces-parent-row-dev"), - ).toBeInTheDocument(); - - await user.click(screen.getByTestId("manage-workspaces-remove-parent-dev")); - - expect(useWorkspacesStore.getState().workspaceParents).toEqual([ - { id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }, - ]); - - await screen.findByTestId("confirmation-modal"); - await user.click(screen.getByTestId("confirm-button")); - - expect(useWorkspacesStore.getState().workspaceParents).toEqual([]); - expect(searchSpy).toHaveBeenCalledWith("/Users/me/dev"); - - await user.click(screen.getByTestId("manage-workspaces-done")); - await waitFor(() => - expect( - screen.queryByTestId("manage-workspaces-modal"), - ).not.toBeInTheDocument(), - ); - - await user.click(screen.getByTestId("workspace-dropdown")); - const refreshedDropdown = await screen.findByTestId( - "workspace-dropdown-menu", - ); - expect( - within(refreshedDropdown).queryByText("repoA"), - ).not.toBeInTheDocument(); - expect( - within(refreshedDropdown).queryByText("repoB"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/features/conversation-panel/new-conversation-button.tsx b/src/components/features/conversation-panel/new-conversation-button.tsx index 0cceeb747..cc5c85e78 100644 --- a/src/components/features/conversation-panel/new-conversation-button.tsx +++ b/src/components/features/conversation-panel/new-conversation-button.tsx @@ -74,14 +74,31 @@ export function NewConversationButton() { return () => window.removeEventListener("keydown", onKeyDown); }, [open, browserOpen, manageOpen]); - const launch = (workingDir?: string) => { + const launch = ( + event: React.MouseEvent, + workingDir?: string, + ) => { if (isCreating) return; + // Cmd-click on macOS / Ctrl-click on Windows+Linux opens the new + // conversation in a background tab. We open `about:blank` synchronously + // inside the click handler so popup blockers treat it as a user gesture, + // then redirect that window once the conversation has been created. + const openInNewTab = event.metaKey || event.ctrlKey; + const newTab = openInNewTab ? window.open("about:blank", "_blank") : null; createConversation( { workingDir }, { onSuccess: (data) => { + const url = `/conversations/${data.conversation_id}`; setOpen(false); - navigate(`/conversations/${data.conversation_id}`); + if (newTab) { + newTab.location.href = url; + } else { + navigate(url); + } + }, + onError: () => { + if (newTab) newTab.close(); }, }, ); @@ -133,7 +150,7 @@ export function NewConversationButton() { type="button" disabled={isCreating} data-testid="launch-no-workspace" - onClick={() => launch()} + onClick={(event) => launch(event)} className={itemClass} > @@ -148,7 +165,7 @@ export function NewConversationButton() { disabled={isCreating} data-testid="launch-workspace" data-workspace-path={w.path} - onClick={() => launch(w.path)} + onClick={(event) => launch(event, w.path)} className={itemClass} > diff --git a/src/components/features/home/home-new-conversation.tsx b/src/components/features/home/home-new-conversation.tsx new file mode 100644 index 000000000..9f9f3db7b --- /dev/null +++ b/src/components/features/home/home-new-conversation.tsx @@ -0,0 +1,18 @@ +import { NewConversationButton } from "#/components/features/conversation-panel/new-conversation-button"; + +/** + * Home-screen wrapper around the same `NewConversationButton` used in the + * left-hand sidebar: a single trigger that opens an inline workspace picker + * popover. The wrapper just centers the button and gives the popover a + * comfortable width on the home page. + */ +export function HomeNewConversation() { + return ( +
+ +
+ ); +} diff --git a/src/components/features/home/new-conversation.tsx b/src/components/features/home/new-conversation.tsx deleted file mode 100644 index 3673d9d6e..000000000 --- a/src/components/features/home/new-conversation.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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"; -import PlusIcon from "#/icons/u-plus.svg?react"; - -export function NewConversation() { - const { t } = useTranslation("openhands"); - - const { navigate } = useNavigation(); - const { - mutate: createConversation, - isPending, - isSuccess, - } = useCreateConversation(); - const isCreatingConversationElsewhere = useIsCreatingConversation(); - - // We check for isSuccess because the app might require time to render - // into the new conversation screen after the conversation is created. - const isCreatingConversation = - isPending || isSuccess || isCreatingConversationElsewhere; - - return ( -
-
-
- - - {t(I18nKey.COMMON$START_FROM_SCRATCH)} - -
-
-
- - {t(I18nKey.HOME$NEW_PROJECT_DESCRIPTION)} - -
- - createConversation( - {}, - { - onSuccess: (data) => - navigate(`/conversations/${data.conversation_id}`), - }, - ) - } - isDisabled={isCreatingConversation} - className="w-auto absolute bottom-5 left-5 right-5 font-semibold" - > - {!isCreatingConversation && t("COMMON$NEW_CONVERSATION")} - {isCreatingConversation && t("HOME$LOADING")} - -
- ); -} diff --git a/src/components/features/home/new-conversation/create-conversation-button.tsx b/src/components/features/home/new-conversation/create-conversation-button.tsx deleted file mode 100644 index e417a90e2..000000000 --- a/src/components/features/home/new-conversation/create-conversation-button.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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("openhands"); - const { navigate } = useNavigation(); - - const { - mutate: createConversation, - isPending, - isSuccess, - } = useCreateConversation(); - const isCreatingConversationElsewhere = useIsCreatingConversation(); - - // We check for isSuccess because the app might require time to render - // into the new conversation screen after the conversation is created. - const isCreatingConversation = - isPending || isSuccess || isCreatingConversationElsewhere; - - const handleCreateConversation = () => { - createConversation( - {}, - { - onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`), - }, - ); - }; - - return ( - - {!isCreatingConversation && t("COMMON$NEW_CONVERSATION")} - {isCreatingConversation && t("HOME$LOADING")} - - ); -} diff --git a/src/components/features/home/new-conversation/new-conversation.tsx b/src/components/features/home/new-conversation/new-conversation.tsx deleted file mode 100644 index 0b511d7d5..000000000 --- a/src/components/features/home/new-conversation/new-conversation.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import PlusIcon from "#/icons/u-plus.svg?react"; -import { CardTitle } from "#/ui/card-title"; -import { Typography } from "#/ui/typography"; -import { CreateConversationButton } from "./create-conversation-button"; -import { Card } from "#/ui/card"; - -export function NewConversation() { - const { t } = useTranslation("openhands"); - - return ( - - }> - {t(I18nKey.COMMON$START_FROM_SCRATCH)} - - - {t(I18nKey.HOME$NEW_PROJECT_DESCRIPTION)} - - - - ); -} diff --git a/src/components/features/home/repo-connector.tsx b/src/components/features/home/repo-connector.tsx deleted file mode 100644 index bc5f65830..000000000 --- a/src/components/features/home/repo-connector.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useUserProviders } from "#/hooks/use-user-providers"; - -import { WorkspaceSelectionForm } from "./workspace-selection-form"; - -export function RepoConnector() { - const { isLoadingSettings } = useUserProviders(); - - // Agent-canvas talks directly to an `agent_server` backend (there is no - // hosted/cloud backend in this build), so the home screen always shows the - // local-workspace launcher rather than a git repository picker. If/when a - // cloud backend is supported, this component should branch on the backend - // mode and render instead. - return ( -
- -
- ); -} diff --git a/src/components/features/home/workspace-dropdown/workspace-dropdown.tsx b/src/components/features/home/workspace-dropdown/workspace-dropdown.tsx deleted file mode 100644 index 4771faf50..000000000 --- a/src/components/features/home/workspace-dropdown/workspace-dropdown.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useState, useMemo, useCallback, useRef } from "react"; -import { useCombobox } from "downshift"; -import { useTranslation } from "react-i18next"; - -import { cn } from "#/utils/utils"; -import { LocalWorkspace } from "#/types/workspace"; -import { I18nKey } from "#/i18n/declaration"; -import RepoIcon from "#/icons/repo.svg?react"; - -import { ClearButton } from "../shared/clear-button"; -import { ToggleButton } from "../shared/toggle-button"; -import { DropdownItem } from "../shared/dropdown-item"; -import { EmptyState } from "../shared/empty-state"; -import { GenericDropdownMenu } from "../shared/generic-dropdown-menu"; - -export interface WorkspaceDropdownProps { - workspaces: LocalWorkspace[]; - value: LocalWorkspace | null; - placeholder?: string; - className?: string; - disabled?: boolean; - /** - * Whether to surface the "Manage Workspaces" entry in the sticky footer. - * Defaults to `workspaces.length > 0` when omitted; pass an explicit value - * if there are workspace parents (whose children may not have loaded yet) - * that should also count as "manageable". - */ - showManage?: boolean; - onChange: (workspace: LocalWorkspace | null) => void; - onAddClick: () => void; - onManageClick: () => void; -} - -export function WorkspaceDropdown({ - workspaces, - value, - placeholder, - className, - disabled = false, - showManage, - onChange, - onAddClick, - onManageClick, -}: WorkspaceDropdownProps) { - const { t } = useTranslation("openhands"); - const [inputValue, setInputValue] = useState(value?.name ?? ""); - const menuRef = useRef(null); - - const filteredWorkspaces = useMemo(() => { - const trimmed = inputValue.trim().toLowerCase(); - if (!trimmed) return workspaces; - return workspaces.filter( - (w) => - w.name.toLowerCase().includes(trimmed) || - w.path.toLowerCase().includes(trimmed), - ); - }, [workspaces, inputValue]); - - const handleSelectionChange = useCallback( - (selectedItem: LocalWorkspace | null) => { - onChange(selectedItem); - if (selectedItem) { - setInputValue(selectedItem.name); - } - }, - [onChange], - ); - - const handleClear = useCallback(() => { - handleSelectionChange(null); - setInputValue(""); - }, [handleSelectionChange]); - - const { - isOpen, - getToggleButtonProps, - getMenuProps, - getInputProps, - highlightedIndex, - getItemProps, - selectedItem, - closeMenu, - } = useCombobox({ - items: filteredWorkspaces, - itemToString: (item) => item?.name ?? "", - selectedItem: value, - onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { - handleSelectionChange(newSelectedItem ?? null); - }, - inputValue, - stateReducer: (state, actionAndChanges) => - actionAndChanges.type === useCombobox.stateChangeTypes.InputClick && - state.isOpen - ? { ...actionAndChanges.changes, isOpen: true } - : actionAndChanges.changes, - }); - - const renderItem = ( - item: LocalWorkspace, - index: number, - itemHighlightedIndex: number, - itemSelectedItem: LocalWorkspace | null, - itemGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any - ) => ( - workspace.name} - getItemKey={(workspace) => workspace.id} - /> - ); - - const renderEmptyState = (emptyInputValue: string) => ( - - ); - - const stickyFooterItem = useMemo( - () => ( -
- - {(showManage ?? workspaces.length > 0) && ( - - )} -
- ), - [onAddClick, onManageClick, t, closeMenu, workspaces.length, showManage], - ); - - return ( -
-
-
- -
- ) => { - setInputValue(e.target.value); - }, - })} - data-testid="workspace-dropdown" - /> - -
- {value && } - -
-
- - - isOpen={isOpen} - filteredItems={filteredWorkspaces} - inputValue={inputValue} - highlightedIndex={highlightedIndex} - selectedItem={selectedItem} - getMenuProps={getMenuProps} - getItemProps={getItemProps} - menuRef={menuRef} - renderItem={renderItem} - renderEmptyState={renderEmptyState} - stickyFooterItem={stickyFooterItem} - testId="workspace-dropdown-menu" - itemKey={(item) => item.id} - /> -
- ); -} diff --git a/src/components/features/home/workspace-selection-form.tsx b/src/components/features/home/workspace-selection-form.tsx deleted file mode 100644 index 7533b2c5d..000000000 --- a/src/components/features/home/workspace-selection-form.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { useNavigation } from "#/context/navigation-context"; -import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; -import { useWorkspacesStore } from "#/stores/workspaces-store"; -import { useResolvedWorkspaces } from "#/hooks/query/use-resolved-workspaces"; -import { LocalWorkspace } from "#/types/workspace"; -import { I18nKey } from "#/i18n/declaration"; -import FolderIcon from "#/icons/folder.svg?react"; - -import { BrandButton } from "../settings/brand-button"; -import { WorkspaceDropdown } from "./workspace-dropdown/workspace-dropdown"; -import { FolderBrowserModal } from "./workspace-dropdown/folder-browser-modal"; -import { ManageWorkspacesModal } from "./workspace-dropdown/manage-workspaces-modal"; - -interface WorkspaceSelectionFormProps { - isLoadingSettings?: boolean; -} - -export function WorkspaceSelectionForm({ - isLoadingSettings = false, -}: WorkspaceSelectionFormProps) { - const { t } = useTranslation("openhands"); - const { navigate } = useNavigation(); - - const { - workspaceParents, - addWorkspaces, - removeWorkspace, - addWorkspaceParents, - removeWorkspaceParent, - } = useWorkspacesStore(); - const { - workspaces, - isLoading: isLoadingWorkspaces, - isError: hasWorkspaceError, - } = useResolvedWorkspaces(); - const [selectedWorkspace, setSelectedWorkspace] = - React.useState(null); - const [isBrowserOpen, setIsBrowserOpen] = React.useState(false); - const [isManageOpen, setIsManageOpen] = React.useState(false); - - const { - mutate: createConversation, - isPending, - isSuccess, - } = useCreateConversation(); - const isCreatingConversationElsewhere = useIsCreatingConversation(); - const isCreatingConversation = - isPending || isSuccess || isCreatingConversationElsewhere; - - const showWorkspaceStatus = workspaceParents.length > 0; - let workspaceStatusText: string | null = null; - if (isLoadingWorkspaces) { - workspaceStatusText = t(I18nKey.HOME$LOADING); - } else if (hasWorkspaceError) { - workspaceStatusText = t(I18nKey.HOME$WORKSPACE_SCAN_ERROR); - } - const isDropdownDisabled = - isLoadingSettings || (isLoadingWorkspaces && workspaces.length === 0); - - const handleLaunch = () => { - if (!selectedWorkspace) return; - createConversation( - { workingDir: selectedWorkspace.path }, - { - onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`), - }, - ); - }; - - return ( -
-
- - - {t(I18nKey.HOME$WORKSPACES_TAB)} - -
- -
- 0 || workspaceParents.length > 0} - onChange={setSelectedWorkspace} - onAddClick={() => setIsBrowserOpen(true)} - onManageClick={() => setIsManageOpen(true)} - className="max-w-auto" - /> - - {showWorkspaceStatus && workspaceStatusText && ( -

- {workspaceStatusText} -

- )} -
- - - {!isCreatingConversation && "Launch"} - {isCreatingConversation && t(I18nKey.HOME$LOADING)} - - - setIsBrowserOpen(false)} - onAdd={(items) => addWorkspaces(items)} - onAddParent={(items) => addWorkspaceParents(items)} - /> - - setIsManageOpen(false)} - onRemove={(path) => { - if (selectedWorkspace?.path === path) { - setSelectedWorkspace(null); - } - removeWorkspace(path); - }} - onRemoveParent={(path) => { - if (selectedWorkspace?.parentPath === path) { - setSelectedWorkspace(null); - } - removeWorkspaceParent(path); - }} - /> -
- ); -} diff --git a/src/routes/home.tsx b/src/routes/home.tsx index 0d45345e8..9ab5e29f4 100644 --- a/src/routes/home.tsx +++ b/src/routes/home.tsx @@ -1,8 +1,7 @@ import { PrefetchPageLinks } from "react-router"; import { HomeHeader } from "#/components/features/home/home-header/home-header"; -import { RepoConnector } from "#/components/features/home/repo-connector"; +import { HomeNewConversation } from "#/components/features/home/home-new-conversation"; import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions"; -import { NewConversation } from "#/components/features/home/new-conversation/new-conversation"; import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations"; ; @@ -15,14 +14,11 @@ function HomeScreen() { > -
-
- - -
+
+