mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
* 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>
200 lines
6.5 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|