Files
OpenHands/__tests__/api/use-create-conversation-metadata.test.ts
T
4ec694e86b Add workspace mode for local conversations (#1293)
* Add workspace mode for local conversations

Selected local workspaces now default to direct repo/folder mode by passing workspaceMode=local_repo through the home launcher and conversation creation stack. The agent-server payload can now send worktree=false, so plain folders do not request git worktree setup by default. Conversations started without a selected workspace continue to request worktree=true.

Adds a pill-style workspace mode selector beside the selected workspace preview with current backend labels limited to Local Repo and Cloud Repo plus the New Worktree option. Stores workspace_mode in client metadata so the selected mode can be preserved alongside selected_workspace.

Coverage added for adapter payload defaults, service-level selected-workspace behavior, home launcher mode selection, and updated comments around worktree-mode grouping. Verified with focused vitest, typecheck, prettier check, and production build.

* Localize workspace mode selector labels via i18n

Replace the hardcoded "Local Repo" / "Cloud Repo" / "New Worktree"
strings with COMMON$WORKSPACE_MODE_* translation keys (all 15 supported
languages). getWorkspaceModeLabel becomes getWorkspaceModeI18nKey,
returning an I18nKey that the selector translates with useTranslation,
matching the getTaskStatusI18nKey convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: failing tests

---------

Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:06:45 +00:00

200 lines
6.5 KiB
TypeScript

import {
ConversationClient,
SettingsClient,
} from "@openhands/typescript-client/clients";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";
const {
mockHttpPost,
mockConversationClient,
mockSettingsClient,
mockGetSettings,
mockGetSettingsForConversation,
mockUseLlmProfiles,
} = vi.hoisted(() => ({
mockHttpPost: vi.fn(),
mockConversationClient: vi.fn(),
mockSettingsClient: vi.fn(),
mockGetSettings: vi.fn(),
mockGetSettingsForConversation: vi.fn(),
mockUseLlmProfiles: vi.fn(),
}));
vi.mock("@openhands/typescript-client/clients", async () => {
const actual = await vi.importActual<
typeof import("@openhands/typescript-client/clients")
>("@openhands/typescript-client/clients");
return {
...actual,
ConversationClient: vi.fn(function ConversationClientMock() {
return mockConversationClient();
}),
SettingsClient: vi.fn(function SettingsClientMock() {
return mockSettingsClient();
}),
VSCodeClient: vi.fn(function VSCodeClientMock() {
return { getUrl: vi.fn() };
}),
};
});
vi.mock("#/api/agent-server-config", () => ({
DEFAULT_WORKING_DIR: "workspace/project",
getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"),
getBakedSessionApiKey: vi.fn(() => "test-session-key"),
getAgentServerSessionApiKey: vi.fn(() => "test-session-key"),
getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"),
buildConversationWorkingDir: vi.fn(
(id: string) => `/state/workspaces/${id.replace(/-/g, "")}`,
),
shouldLoadPublicSkills: vi.fn(() => true),
syncBakedSessionApiKey: vi.fn(),
}));
vi.mock("#/api/settings-service/settings-service.api", () => ({
default: {
getSettings: mockGetSettings,
getSettingsForConversation: mockGetSettingsForConversation,
},
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({ trackConversationCreated: vi.fn() }),
}));
vi.mock("#/hooks/query/use-llm-profiles", () => ({
useLlmProfiles: () => mockUseLlmProfiles(),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return React.createElement(QueryClientProvider, { client }, children);
};
describe("useCreateConversation persists selected repository metadata", () => {
beforeEach(() => {
window.localStorage.clear();
mockUseLlmProfiles.mockReset();
// Default: no active profile, so metadata is written only for repo/
// workspace attachments (as before). Individual tests override this.
mockUseLlmProfiles.mockReturnValue({ data: { active_profile: null } });
mockHttpPost.mockReset();
mockGetSettings.mockReset();
mockGetSettingsForConversation.mockReset();
mockGetSettings.mockResolvedValue({
agent_settings: { llm: { model: "gpt-4o" } },
conversation_settings: {},
});
mockGetSettingsForConversation.mockResolvedValue({
agentSettings: { llm: { model: "gpt-4o" } },
conversationSettings: {},
secretsEncrypted: true,
});
mockConversationClient.mockReset();
vi.mocked(ConversationClient).mockClear();
vi.mocked(SettingsClient).mockClear();
mockConversationClient.mockReturnValue({
createConversation: async (payload: unknown) => {
const response = await mockHttpPost("/api/conversations", payload);
return response.data;
},
});
mockSettingsClient.mockReturnValue({
listSecrets: vi.fn().mockResolvedValue({ secrets: [] }),
});
mockHttpPost.mockResolvedValue({
data: {
id: "conv-new",
created_at: "2026-05-05T00:00:00Z",
updated_at: "2026-05-05T00:00:00Z",
},
});
});
afterEach(() => {
window.localStorage.clear();
});
it("stores the selected repo/branch/provider in the metadata store after a successful create", async () => {
const { result } = renderHook(() => useCreateConversation(), { wrapper });
result.current.mutate({
query: "ship it",
repository: {
name: "octocat/hello-world",
gitProvider: "github",
branch: "main",
},
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getStoredConversationMetadata("conv-new")).toEqual({
selected_repository: "octocat/hello-world",
selected_branch: "main",
git_provider: "github",
selected_workspace: null,
workspace_mode: "new_worktree",
});
});
it("stores the selected workspace path when only a workspace (no repo) is attached", async () => {
const { result } = renderHook(() => useCreateConversation(), { wrapper });
result.current.mutate({
query: "poke at this repo",
workingDir: "/home/me/code/some-project",
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// We persist the workspace path so `useHasAttachedSource` can default
// the Files tab to diff view even when no repo was picked.
expect(getStoredConversationMetadata("conv-new")).toEqual({
selected_repository: null,
selected_branch: null,
git_provider: null,
selected_workspace: "/home/me/code/some-project",
workspace_mode: "local_repo",
});
});
it("does not write metadata when neither a repository nor a workspace is attached", async () => {
const { result } = renderHook(() => useCreateConversation(), { wrapper });
result.current.mutate({ query: "scratch session" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getStoredConversationMetadata("conv-new")).toBeNull();
});
it("stamps the active LLM profile even when no repo or workspace is attached (#1082)", async () => {
mockUseLlmProfiles.mockReturnValue({
data: { active_profile: "team-default" },
});
const { result } = renderHook(() => useCreateConversation(), { wrapper });
result.current.mutate({ query: "scratch session" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getStoredConversationMetadata("conv-new")).toEqual({
selected_repository: null,
selected_branch: null,
git_provider: null,
selected_workspace: null,
workspace_mode: null,
active_profile: "team-default",
});
});
});