mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:08:23 +08:00
Show agent-server version errors for workspaces (#742)
* Show workspace version errors in canvas * Use current agent server version in mocks * Pin merged typescript client dependency * Use typescript client v1.23 release * Clarify typescript client release pinning guidance --------- Co-authored-by: neubig <398875+neubig@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
neubig
parent
27fe2469f2
commit
8cab54e0b1
@@ -97,16 +97,22 @@ jobs:
|
||||
echo "main HEAD SHA: $MAIN_SHA"
|
||||
|
||||
# Find the snapshot-tests.yml run triggered by exactly this commit.
|
||||
RUN_ID=$(gh api \
|
||||
if ! RUN_ID=$(gh api \
|
||||
"/repos/${{ github.repository }}/actions/workflows/snapshot-tests.yml/runs?branch=main&per_page=20" \
|
||||
--jq "[.workflow_runs[] | select(.head_sha == \"$MAIN_SHA\")] | .[0].id // empty")
|
||||
--jq "[.workflow_runs[] | select(.head_sha == \"$MAIN_SHA\")] | .[0].id // empty"); then
|
||||
echo "::warning::Could not query main snapshot workflow runs; proceeding with latest available artifact."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "No snapshot-tests run found for main SHA $MAIN_SHA — proceeding."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STATUS=$(gh api "/repos/${{ github.repository }}/actions/runs/$RUN_ID" --jq '.status')
|
||||
if ! STATUS=$(gh api "/repos/${{ github.repository }}/actions/runs/$RUN_ID" --jq '.status'); then
|
||||
echo "::warning::Could not query main snapshot workflow run status; proceeding with latest available artifact."
|
||||
exit 0
|
||||
fi
|
||||
echo "Run $RUN_ID status: $STATUS"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
echo "Already completed — proceeding."
|
||||
@@ -116,7 +122,10 @@ jobs:
|
||||
echo "Waiting for run $RUN_ID to complete (max 10 min)..."
|
||||
for i in $(seq 1 60); do
|
||||
sleep 10
|
||||
STATUS=$(gh api "/repos/${{ github.repository }}/actions/runs/$RUN_ID" --jq '.status')
|
||||
if ! STATUS=$(gh api "/repos/${{ github.repository }}/actions/runs/$RUN_ID" --jq '.status'); then
|
||||
echo "::warning::Could not query main snapshot workflow run status; proceeding with latest available artifact."
|
||||
exit 0
|
||||
fi
|
||||
echo " [$((i * 10))s] status: $STATUS"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
echo "Main baseline workflow completed."
|
||||
|
||||
@@ -156,7 +156,7 @@ you are running inside of — NOT the automation backend.
|
||||
- `src/components/shared/buttons/styled-tooltip.tsx` should keep HeroUI tooltip animations disabled in Vitest (`disableAnimation` when `import.meta.env.MODE === "test"`); otherwise full-suite runs can end with unhandled `window is not defined` rejections from `framer-motion` after jsdom teardown (seen via `recent-conversation` tests in CI).
|
||||
- `__tests__/i18n/library-namespace.test.ts` imports the full library entry and can exceed Vitest's default 5s timeout under full-suite load; keep an explicit higher timeout on that case unless the test is substantially narrowed.
|
||||
|
||||
- `@openhands/typescript-client` is currently pinned to commit `ef62e82fc3dfb03991a1c8025429caf354427263` because the package metadata needed by this PR has not been published as a consistent npm/tagged release yet. That commit ships the needed typed clients plus subpath exports for `client/http-client`, `events/remote-events-list`, and `workspace/remote-workspace`. `RemoteWorkspace.gitChanges`/`gitDiff` accept an optional `{ ref }` option; agent-canvas passes `'HEAD'` so the changes panel reflects working-tree + index versus the latest commit (i.e. staged + unstaged) instead of a diff against the upstream/default branch.
|
||||
- `@openhands/typescript-client` should be pinned to a released git tag/version rather than an unreleased commit SHA; when agent-canvas needs new client API, release/tag the client first and then update the dependency to that tag. Released versions should include the typed clients, agent-server version compatibility helpers, `WorkspacesClient`, `ConversationClient.switchLLM`, and subpath exports for `client/http-client`, `events/remote-events-list`, and `workspace/remote-workspace` needed by the agent-canvas agent-server integration. `RemoteWorkspace.gitChanges`/`gitDiff` accept an optional `{ ref }` option; agent-canvas passes `'HEAD'` so the changes panel reflects working-tree + index versus the latest commit (i.e. staged + unstaged) instead of a diff against the upstream/default branch.
|
||||
- The `@openhands/typescript-client` git dep must be expressed as a `git+https://github.com/...` URL in both `package.json` and the top-level dep entry of `package-lock.json`; the `github:OpenHands/...` shorthand normalizes to `git+ssh://` inside the lockfile, and Vercel's build environment has no GitHub SSH key, so an ssh-pinned lockfile makes Vercel fall back to a stale cached tarball and the bundler then fails with `[MISSING_EXPORT] ConversationClient/FileClient/SharedClient is not exported by .../dist/clients.js`. `scripts/vercel-install.sh` (wired up via `vercel.json`'s `installCommand`) defensively rewrites any leftover `git+ssh://git@github.com/` resolved URLs to `git+https://github.com/` and adds matching `git config --global url..insteadOf` aliases before invoking `npm ci`, so a future regression that re-introduces an ssh-pinned lockfile entry still builds on Vercel. See GitHub issue #384 for the original failure and PR #382 for the prior single-shot lockfile fix that this generalizes.
|
||||
|
||||
## API Access Rules
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import WorkspacesService from "#/api/workspaces-service/workspaces-service.api";
|
||||
|
||||
const { mockGet, mockPost, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
const { mockGet, mockPost, mockDelete, mockAssertAgentServerSupports } =
|
||||
vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockAssertAgentServerSupports: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@openhands/typescript-client/client/http-client", () => ({
|
||||
HttpClient: vi.fn(function HttpClientMock() {
|
||||
@@ -20,6 +22,17 @@ vi.mock("@openhands/typescript-client/client/http-client", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
AgentServerFeatureRequirements: {
|
||||
workspaces: {
|
||||
feature: "workspaces",
|
||||
displayName: "Workspaces",
|
||||
minVersion: "1.23.0",
|
||||
},
|
||||
},
|
||||
assertAgentServerSupports: mockAssertAgentServerSupports,
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
@@ -36,6 +49,12 @@ beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockDelete.mockReset();
|
||||
mockAssertAgentServerSupports.mockReset();
|
||||
mockAssertAgentServerSupports.mockResolvedValue({
|
||||
version: "1.23.0",
|
||||
uptime: 1,
|
||||
idle_time: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -59,9 +78,26 @@ describe("WorkspacesService", () => {
|
||||
expect(result).toEqual(body);
|
||||
});
|
||||
|
||||
it("propagates the typed old-server error before calling /api/workspaces", async () => {
|
||||
// Arrange
|
||||
const error = {
|
||||
code: "AGENT_SERVER_VERSION_TOO_OLD",
|
||||
feature: "workspaces",
|
||||
requiredVersion: "1.23.0",
|
||||
actualVersion: "1.22.1",
|
||||
};
|
||||
mockAssertAgentServerSupports.mockRejectedValue(error);
|
||||
|
||||
// Act + Assert
|
||||
await expect(WorkspacesService.listWorkspaces()).rejects.toBe(error);
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("addWorkspaces POSTs the items wrapped in a workspaces envelope", async () => {
|
||||
// Arrange
|
||||
mockPost.mockResolvedValue({ data: { workspaces: [], workspaceParents: [] } });
|
||||
mockPost.mockResolvedValue({
|
||||
data: { workspaces: [], workspaceParents: [] },
|
||||
});
|
||||
const items = [{ id: "/a", name: "a", path: "/a", parentPath: "/p" }];
|
||||
|
||||
// Act
|
||||
@@ -75,7 +111,9 @@ describe("WorkspacesService", () => {
|
||||
|
||||
it("addWorkspaceParents POSTs the items wrapped in a parents envelope", async () => {
|
||||
// Arrange
|
||||
mockPost.mockResolvedValue({ data: { workspaces: [], workspaceParents: [] } });
|
||||
mockPost.mockResolvedValue({
|
||||
data: { workspaces: [], workspaceParents: [] },
|
||||
});
|
||||
const parents = [{ id: "/p", name: "p", path: "/p" }];
|
||||
|
||||
// Act
|
||||
|
||||
@@ -6,6 +6,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { HomeChatLauncher } from "#/components/features/home/home-chat-launcher";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
import WorkspacesService from "#/api/workspaces-service/workspaces-service.api";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
const mockUseActiveBackend = vi.fn();
|
||||
@@ -207,8 +208,13 @@ const cloudBackend = {
|
||||
|
||||
describe("HomeChatLauncher", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockUseActiveBackend.mockReturnValue(localBackend);
|
||||
vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({
|
||||
workspaces: [],
|
||||
workspaceParents: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -251,7 +257,9 @@ describe("HomeChatLauncher", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("open-workspace-button"));
|
||||
await user.click(await screen.findByTestId("stub-workspace-dialog-confirm"));
|
||||
await user.click(
|
||||
await screen.findByTestId("stub-workspace-dialog-confirm"),
|
||||
);
|
||||
await user.click(screen.getByTestId("stub-chat-submit"));
|
||||
|
||||
await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1));
|
||||
@@ -269,6 +277,27 @@ describe("HomeChatLauncher", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the local workspace launcher when the agent server is too old", async () => {
|
||||
vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({
|
||||
code: "AGENT_SERVER_VERSION_TOO_OLD",
|
||||
feature: "workspaces",
|
||||
requiredVersion: "1.23.0",
|
||||
actualVersion: "1.22.1",
|
||||
});
|
||||
|
||||
renderLauncher();
|
||||
const user = userEvent.setup();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("open-workspace-button")).toBeDisabled(),
|
||||
);
|
||||
const button = screen.getByTestId("open-workspace-button");
|
||||
await user.hover(button.parentElement ?? button);
|
||||
|
||||
expect(
|
||||
await screen.findByText("HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the picked repository + branch payload on a cloud backend", async () => {
|
||||
mockUseActiveBackend.mockReturnValue(cloudBackend);
|
||||
const createSpy = vi
|
||||
@@ -304,9 +333,10 @@ describe("HomeChatLauncher", () => {
|
||||
});
|
||||
|
||||
it("surfaces a toast and skips navigation when conversation creation fails", async () => {
|
||||
vi.spyOn(AgentServerConversationService, "createConversation").mockRejectedValue(
|
||||
new Error("Network down"),
|
||||
);
|
||||
vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
"createConversation",
|
||||
).mockRejectedValue(new Error("Network down"));
|
||||
|
||||
renderLauncher();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -121,6 +121,45 @@ describe("WorkspaceSelectionForm (server-backed workspaces)", () => {
|
||||
expect(await within(menu).findByText("repo1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a version-specific workspace message for old agent servers", async () => {
|
||||
vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({
|
||||
code: "AGENT_SERVER_VERSION_TOO_OLD",
|
||||
feature: "workspaces",
|
||||
requiredVersion: "1.23.0",
|
||||
actualVersion: "1.22.1",
|
||||
});
|
||||
|
||||
render(<WorkspaceSelectionForm />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-dropdown")).toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-dropdown")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"HOME$WORKSPACES_UNSUPPORTED_PLACEHOLDER",
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("workspace-status-message")).toHaveTextContent(
|
||||
"HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER",
|
||||
);
|
||||
});
|
||||
|
||||
it("Add Workspace dispatches addWorkspaces to the agent-server", async () => {
|
||||
// Arrange
|
||||
const addSpy = vi
|
||||
|
||||
Generated
+2
-3
@@ -13,7 +13,7 @@
|
||||
"@microlink/react-json-view": "1.31.20",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#7b33f64ffccf95f7ccd2ec640aeae84ab1cc2c75",
|
||||
"@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#af9688c493d465b38c99dbac2302d462c76dc8ff",
|
||||
"@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0",
|
||||
"@react-router/node": "7.14.2",
|
||||
"@react-router/serve": "7.14.2",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
@@ -3455,8 +3455,7 @@
|
||||
},
|
||||
"node_modules/@openhands/typescript-client": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#af9688c493d465b38c99dbac2302d462c76dc8ff",
|
||||
"integrity": "sha512-JAf4cQ594Zwrjbc51A3/toES8/G5FReGZFayB6v5fZRGJsmCOrDg0lnfBlqHjspt1d4Bz6qHceBFLzhG/I2Oyg==",
|
||||
"resolved": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@openrouter/sdk": "^0.12.35",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"@microlink/react-json-view": "1.31.20",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#7b33f64ffccf95f7ccd2ec640aeae84ab1cc2c75",
|
||||
"@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#af9688c493d465b38c99dbac2302d462c76dc8ff",
|
||||
"@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0",
|
||||
"@react-router/node": "7.14.2",
|
||||
"@react-router/serve": "7.14.2",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* server (workspace/.openhands/workspaces.json). All clients pointed at
|
||||
* the same agent-server see the same list.
|
||||
*
|
||||
* No SDK client exists for this resource yet, so we call HttpClient directly
|
||||
* — the same pattern used elsewhere (e.g. switch_llm in the conversation
|
||||
* service). The lint test at src/api/no-direct-agent-server-calls.test.ts
|
||||
* allows HttpClient and only rejects raw axios / createHttpClient.
|
||||
* This service preflights workspace support through the SDK compatibility
|
||||
* helper so old agent-server backends fail with a typed version error instead
|
||||
* of surfacing a generic 404 from /api/workspaces.
|
||||
*/
|
||||
import {
|
||||
AgentServerFeatureRequirements,
|
||||
assertAgentServerSupports,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
import { HttpClient } from "@openhands/typescript-client/client/http-client";
|
||||
|
||||
import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace";
|
||||
@@ -20,33 +23,42 @@ export interface WorkspacesListResponse {
|
||||
workspaceParents: LocalWorkspaceParent[];
|
||||
}
|
||||
|
||||
function client() {
|
||||
return new HttpClient(getAgentServerHttpClientOptions());
|
||||
async function supportedClient() {
|
||||
const httpClient = new HttpClient(getAgentServerHttpClientOptions());
|
||||
await assertAgentServerSupports(
|
||||
httpClient,
|
||||
AgentServerFeatureRequirements.workspaces,
|
||||
);
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
class WorkspacesService {
|
||||
static async listWorkspaces(): Promise<WorkspacesListResponse> {
|
||||
const res = await client().get<WorkspacesListResponse>("/api/workspaces");
|
||||
const client = await supportedClient();
|
||||
const res = await client.get<WorkspacesListResponse>("/api/workspaces");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
static async addWorkspaces(
|
||||
items: LocalWorkspace[],
|
||||
): Promise<WorkspacesListResponse> {
|
||||
const res = await client().post<WorkspacesListResponse>("/api/workspaces", {
|
||||
const client = await supportedClient();
|
||||
const res = await client.post<WorkspacesListResponse>("/api/workspaces", {
|
||||
workspaces: items,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
static async removeWorkspace(path: string): Promise<void> {
|
||||
await client().delete(`/api/workspaces?path=${encodeURIComponent(path)}`);
|
||||
const client = await supportedClient();
|
||||
await client.delete(`/api/workspaces?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
static async addWorkspaceParents(
|
||||
items: LocalWorkspaceParent[],
|
||||
): Promise<WorkspacesListResponse> {
|
||||
const res = await client().post<WorkspacesListResponse>(
|
||||
const client = await supportedClient();
|
||||
const res = await client.post<WorkspacesListResponse>(
|
||||
"/api/workspaces/parents",
|
||||
{ parents: items },
|
||||
);
|
||||
@@ -54,7 +66,8 @@ class WorkspacesService {
|
||||
}
|
||||
|
||||
static async removeWorkspaceParent(path: string): Promise<void> {
|
||||
await client().delete(
|
||||
const client = await supportedClient();
|
||||
await client.delete(
|
||||
`/api/workspaces/parents?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces";
|
||||
import { useResolvedWorkspaces } from "#/hooks/query/use-resolved-workspaces";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility";
|
||||
import RepoIcon from "#/icons/repo.svg?react";
|
||||
|
||||
import { FolderBrowserModal } from "#/components/features/home/workspace-dropdown/folder-browser-modal";
|
||||
import { ManageWorkspacesModal } from "#/components/features/home/workspace-dropdown/manage-workspaces-modal";
|
||||
|
||||
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { NEW_CONVERSATION_DROPDOWN_SURFACE } from "./new-conversation-dropdown-styles";
|
||||
import { usePopoverFixedPlacement } from "#/hooks/use-popover-fixed-placement";
|
||||
@@ -68,13 +70,17 @@ export function LocalNewConversationMenu({
|
||||
enabled: useFixedPlacement,
|
||||
});
|
||||
|
||||
const { data: workspacesData } = useLocalWorkspaces();
|
||||
const { data: workspacesData, error: workspacesError } = useLocalWorkspaces();
|
||||
const workspaceParents = workspacesData?.workspaceParents ?? [];
|
||||
const { mutate: addWorkspaces } = useAddWorkspaces();
|
||||
const { mutate: removeWorkspace } = useRemoveWorkspace();
|
||||
const { mutate: addWorkspaceParents } = useAddWorkspaceParents();
|
||||
const { mutate: removeWorkspaceParent } = useRemoveWorkspaceParent();
|
||||
const { workspaces } = useResolvedWorkspaces();
|
||||
const workspacesUnsupportedMessage = getWorkspacesUnsupportedMessage(
|
||||
workspacesError,
|
||||
t,
|
||||
);
|
||||
const [browserOpen, setBrowserOpen] = React.useState(false);
|
||||
const [manageOpen, setManageOpen] = React.useState(false);
|
||||
|
||||
@@ -139,6 +145,7 @@ export function LocalNewConversationMenu({
|
||||
}, []);
|
||||
|
||||
const showPopover = open && (!useFixedPlacement || fixedBox !== null);
|
||||
const workspaceActionsDisabled = Boolean(workspacesUnsupportedMessage);
|
||||
|
||||
const fixedStyle: React.CSSProperties | undefined =
|
||||
useFixedPlacement && fixedBox
|
||||
@@ -150,6 +157,32 @@ export function LocalNewConversationMenu({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const addWorkspacesButton = (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-workspaces-button"
|
||||
disabled={workspaceActionsDisabled}
|
||||
onMouseDown={keepPopoverOpenOnMouseDown}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (workspaceActionsDisabled) return;
|
||||
setBrowserOpen(true);
|
||||
}}
|
||||
className={itemClass}
|
||||
>
|
||||
{t(I18nKey.HOME$ADD_WORKSPACES)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const addWorkspacesControl = workspacesUnsupportedMessage ? (
|
||||
<StyledTooltip content={workspacesUnsupportedMessage} placement="top">
|
||||
<span className="block">{addWorkspacesButton}</span>
|
||||
</StyledTooltip>
|
||||
) : (
|
||||
addWorkspacesButton
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(!useFixedPlacement && "relative", className)}
|
||||
@@ -213,19 +246,7 @@ export function LocalNewConversationMenu({
|
||||
inset="menu"
|
||||
testId="new-conversation-menu-footer-divider"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-workspaces-button"
|
||||
onMouseDown={keepPopoverOpenOnMouseDown}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setBrowserOpen(true);
|
||||
}}
|
||||
className={itemClass}
|
||||
>
|
||||
{t(I18nKey.HOME$ADD_WORKSPACES)}
|
||||
</button>
|
||||
{addWorkspacesControl}
|
||||
{(workspaces.length > 0 || workspaceParents.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CustomChatInput } from "#/components/features/chat/custom-chat-input";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces";
|
||||
import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor";
|
||||
import { useNavigation } from "#/context/navigation-context";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
displayErrorToast,
|
||||
TOAST_OPTIONS,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility";
|
||||
import { HomeHeaderTitle } from "./home-header/home-header-title";
|
||||
import { OpenLauncherButton } from "./open-launcher-button";
|
||||
import { OpenWorkspaceDialog } from "./open-workspace-dialog";
|
||||
@@ -38,6 +40,10 @@ export function HomeChatLauncher() {
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const isCreatingElsewhere = useIsCreatingConversation();
|
||||
const isCreating = isPending || isCreatingElsewhere;
|
||||
const { error: workspacesError } = useLocalWorkspaces({ enabled: isLocal });
|
||||
const workspacesUnsupportedMessage = isLocal
|
||||
? getWorkspacesUnsupportedMessage(workspacesError, t)
|
||||
: null;
|
||||
|
||||
const hasSelection = isLocal
|
||||
? !!pendingWorkspace
|
||||
@@ -120,7 +126,8 @@ export function HomeChatLauncher() {
|
||||
<OpenLauncherButton
|
||||
kind={isLocal ? "local" : "cloud"}
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isCreating}
|
||||
disabled={isCreating || Boolean(workspacesUnsupportedMessage)}
|
||||
disabledTooltip={workspacesUnsupportedMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
||||
import {
|
||||
formControlBorderClassName,
|
||||
formControlSurfaceClassName,
|
||||
@@ -13,12 +14,14 @@ interface OpenLauncherButtonProps {
|
||||
kind: "local" | "cloud";
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string | null;
|
||||
}
|
||||
|
||||
export function OpenLauncherButton({
|
||||
kind,
|
||||
onClick,
|
||||
disabled = false,
|
||||
disabledTooltip,
|
||||
}: OpenLauncherButtonProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
@@ -28,7 +31,7 @@ export function OpenLauncherButton({
|
||||
: t(I18nKey.COMMON$OPEN_REPOSITORY);
|
||||
const testId = isLocal ? "open-workspace-button" : "open-repository-button";
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
@@ -54,4 +57,14 @@ export function OpenLauncherButton({
|
||||
<span className="text-sm font-normal leading-5">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!disabledTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTooltip content={disabledTooltip} placement="top">
|
||||
<span className="inline-flex">{button}</span>
|
||||
</StyledTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formControlFieldClassName } from "#/utils/form-control-classes";
|
||||
import { LocalWorkspace } from "#/types/workspace";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import RepoIcon from "#/icons/repo.svg?react";
|
||||
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
||||
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
@@ -20,6 +21,7 @@ export interface WorkspaceDropdownProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string | null;
|
||||
/**
|
||||
* Whether to surface the "Manage Workspaces" entry in the sticky footer.
|
||||
* Defaults to `workspaces.length > 0` when omitted; pass an explicit value
|
||||
@@ -38,6 +40,7 @@ export function WorkspaceDropdown({
|
||||
placeholder,
|
||||
className,
|
||||
disabled = false,
|
||||
disabledTooltip,
|
||||
showManage,
|
||||
onChange,
|
||||
onAddClick,
|
||||
@@ -167,7 +170,7 @@ export function WorkspaceDropdown({
|
||||
[onAddClick, onManageClick, t, closeMenu, workspaces.length, showManage],
|
||||
);
|
||||
|
||||
return (
|
||||
const control = (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="group relative text-[var(--oh-muted)] hover:text-white">
|
||||
<div className="absolute left-2 top-1/2 transform -translate-y-1/2 z-10">
|
||||
@@ -217,4 +220,14 @@ export function WorkspaceDropdown({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!disabledTooltip) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTooltip content={disabledTooltip} placement="top">
|
||||
<span className="block">{control}</span>
|
||||
</StyledTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility";
|
||||
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { WorkspaceDropdown } from "./workspace-dropdown/workspace-dropdown";
|
||||
@@ -40,7 +41,7 @@ export function WorkspaceSelectionForm({
|
||||
const { t } = useTranslation("openhands");
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
const { data: workspacesData } = useLocalWorkspaces();
|
||||
const { data: workspacesData, error: workspacesError } = useLocalWorkspaces();
|
||||
const workspaceParents = workspacesData?.workspaceParents ?? [];
|
||||
const { mutate: addWorkspaces } = useAddWorkspaces();
|
||||
const { mutate: removeWorkspace } = useRemoveWorkspace();
|
||||
@@ -50,7 +51,12 @@ export function WorkspaceSelectionForm({
|
||||
workspaces,
|
||||
isLoading: isLoadingWorkspaces,
|
||||
isError: hasWorkspaceError,
|
||||
error: resolvedWorkspacesError,
|
||||
} = useResolvedWorkspaces();
|
||||
const workspacesUnsupportedMessage = getWorkspacesUnsupportedMessage(
|
||||
workspacesError ?? resolvedWorkspacesError,
|
||||
t,
|
||||
);
|
||||
const [selectedWorkspace, setSelectedWorkspace] =
|
||||
React.useState<LocalWorkspace | null>(null);
|
||||
const [isBrowserOpen, setIsBrowserOpen] = React.useState(false);
|
||||
@@ -65,15 +71,20 @@ export function WorkspaceSelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
const showWorkspaceStatus = workspaceParents.length > 0;
|
||||
const showWorkspaceStatus =
|
||||
workspaceParents.length > 0 || Boolean(workspacesUnsupportedMessage);
|
||||
let workspaceStatusText: string | null = null;
|
||||
if (isLoadingWorkspaces) {
|
||||
if (workspacesUnsupportedMessage) {
|
||||
workspaceStatusText = workspacesUnsupportedMessage;
|
||||
} else if (isLoadingWorkspaces) {
|
||||
workspaceStatusText = t(I18nKey.HOME$LOADING);
|
||||
} else if (hasWorkspaceError) {
|
||||
workspaceStatusText = t(I18nKey.HOME$WORKSPACE_SCAN_ERROR);
|
||||
}
|
||||
const isDropdownDisabled =
|
||||
isLoadingSettings || (isLoadingWorkspaces && workspaces.length === 0);
|
||||
Boolean(workspacesUnsupportedMessage) ||
|
||||
isLoadingSettings ||
|
||||
(isLoadingWorkspaces && workspaces.length === 0);
|
||||
|
||||
const handleLaunch = () => {
|
||||
if (!selectedWorkspace) return;
|
||||
@@ -106,8 +117,15 @@ export function WorkspaceSelectionForm({
|
||||
<WorkspaceDropdown
|
||||
workspaces={workspaces}
|
||||
value={selectedWorkspace}
|
||||
placeholder={isDropdownDisabled ? t(I18nKey.HOME$LOADING) : undefined}
|
||||
placeholder={
|
||||
workspacesUnsupportedMessage
|
||||
? t(I18nKey.HOME$WORKSPACES_UNSUPPORTED_PLACEHOLDER)
|
||||
: isDropdownDisabled
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: undefined
|
||||
}
|
||||
disabled={isDropdownDisabled}
|
||||
disabledTooltip={workspacesUnsupportedMessage}
|
||||
showManage={workspaces.length > 0 || workspaceParents.length > 0}
|
||||
onChange={setSelectedWorkspace}
|
||||
onAddClick={() => setIsBrowserOpen(true)}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isAgentServerVersionError } from "@openhands/typescript-client/clients";
|
||||
|
||||
import WorkspacesService, {
|
||||
WorkspacesListResponse,
|
||||
} from "#/api/workspaces-service/workspaces-service.api";
|
||||
import { LOCAL_WORKSPACES_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
export function useLocalWorkspaces() {
|
||||
interface UseLocalWorkspacesOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useLocalWorkspaces({
|
||||
enabled = true,
|
||||
}: UseLocalWorkspacesOptions = {}) {
|
||||
return useQuery<WorkspacesListResponse>({
|
||||
queryKey: LOCAL_WORKSPACES_QUERY_KEYS.all,
|
||||
queryFn: () => WorkspacesService.listWorkspaces(),
|
||||
enabled,
|
||||
retry: (failureCount, error) =>
|
||||
!isAgentServerVersionError(error) && failureCount < 3,
|
||||
meta: { disableToast: true },
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { FileClient } from "@openhands/typescript-client/clients";
|
||||
import {
|
||||
FileClient,
|
||||
isAgentServerVersionError,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
|
||||
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
||||
import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces";
|
||||
@@ -10,6 +13,7 @@ interface UseResolvedWorkspacesResult {
|
||||
workspaces: LocalWorkspace[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +49,9 @@ export function useResolvedWorkspaces(): UseResolvedWorkspacesResult {
|
||||
data,
|
||||
isLoading: isLoadingList,
|
||||
isError: isErrorList,
|
||||
error: listError,
|
||||
} = useLocalWorkspaces();
|
||||
const workspacesUnsupported = isAgentServerVersionError(listError);
|
||||
const workspaces = data?.workspaces ?? [];
|
||||
const storedParents = data?.workspaceParents ?? [];
|
||||
|
||||
@@ -55,23 +61,26 @@ export function useResolvedWorkspaces(): UseResolvedWorkspacesResult {
|
||||
const seen = new Set(storedParents.map((p) => p.path));
|
||||
// Filter out implicit parents that conflict with user-added ones (by path)
|
||||
// so custom names/ids are preserved.
|
||||
const implicitParents = INCLUDE_IMPLICIT_WORKSPACE_PARENTS
|
||||
? IMPLICIT_WORKSPACE_PARENTS
|
||||
: [];
|
||||
const implicitParents =
|
||||
INCLUDE_IMPLICIT_WORKSPACE_PARENTS && !workspacesUnsupported
|
||||
? IMPLICIT_WORKSPACE_PARENTS
|
||||
: [];
|
||||
const extras = implicitParents.filter((p) => !seen.has(p.path));
|
||||
return extras.length === 0 ? storedParents : [...storedParents, ...extras];
|
||||
}, [storedParents]);
|
||||
}, [storedParents, workspacesUnsupported]);
|
||||
|
||||
const parentQueries = useQueries({
|
||||
queries: workspaceParents.map((parent) => ({
|
||||
queryKey: ["file", "search_subdirs", parent.path],
|
||||
queryFn: () =>
|
||||
new FileClient(getAgentServerClientOptions()).searchSubdirectories(
|
||||
parent.path,
|
||||
),
|
||||
retry: false,
|
||||
meta: { disableToast: true },
|
||||
})),
|
||||
queries: workspacesUnsupported
|
||||
? []
|
||||
: workspaceParents.map((parent) => ({
|
||||
queryKey: ["file", "search_subdirs", parent.path],
|
||||
queryFn: () =>
|
||||
new FileClient(getAgentServerClientOptions()).searchSubdirectories(
|
||||
parent.path,
|
||||
),
|
||||
retry: false,
|
||||
meta: { disableToast: true },
|
||||
})),
|
||||
});
|
||||
|
||||
const isLoading = isLoadingList || parentQueries.some((q) => q.isLoading);
|
||||
@@ -115,5 +124,5 @@ export function useResolvedWorkspaces(): UseResolvedWorkspacesResult {
|
||||
return Array.from(byPath.values());
|
||||
}, [workspaces, workspaceParents, queriesFingerprint]);
|
||||
|
||||
return { workspaces: merged, isLoading, isError };
|
||||
return { workspaces: merged, isLoading, isError, error: listError };
|
||||
}
|
||||
|
||||
@@ -26315,6 +26315,40 @@
|
||||
"uk": "Не вдалося завантажити деякі папки робочого простору.",
|
||||
"ca": "No s'han pogut carregar algunes carpetes de l'espai de treball."
|
||||
},
|
||||
"HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER": {
|
||||
"en": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"ja": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"zh-CN": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"zh-TW": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"ko-KR": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"no": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"it": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"pt": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"es": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"ar": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"fr": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"tr": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"de": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"uk": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version.",
|
||||
"ca": "Workspaces are supported only for agent-server {{requiredVersion}} or higher. This backend is running {{actualVersion}}. Please upgrade your agent-server backend to the latest version."
|
||||
},
|
||||
"HOME$WORKSPACES_UNSUPPORTED_PLACEHOLDER": {
|
||||
"en": "Upgrade agent-server to use workspaces",
|
||||
"ja": "Upgrade agent-server to use workspaces",
|
||||
"zh-CN": "Upgrade agent-server to use workspaces",
|
||||
"zh-TW": "Upgrade agent-server to use workspaces",
|
||||
"ko-KR": "Upgrade agent-server to use workspaces",
|
||||
"no": "Upgrade agent-server to use workspaces",
|
||||
"it": "Upgrade agent-server to use workspaces",
|
||||
"pt": "Upgrade agent-server to use workspaces",
|
||||
"es": "Upgrade agent-server to use workspaces",
|
||||
"ar": "Upgrade agent-server to use workspaces",
|
||||
"fr": "Upgrade agent-server to use workspaces",
|
||||
"tr": "Upgrade agent-server to use workspaces",
|
||||
"de": "Upgrade agent-server to use workspaces",
|
||||
"uk": "Upgrade agent-server to use workspaces",
|
||||
"ca": "Upgrade agent-server to use workspaces"
|
||||
},
|
||||
"EVENT_GROUP$ACTIONS_COMPLETED": {
|
||||
"en": "{{count}} actions completed",
|
||||
"ja": "{{count}} 件のアクションが完了しました",
|
||||
|
||||
@@ -488,6 +488,8 @@ const MOCK_VERIFIED_MODELS_BY_PROVIDER = MOCK_MODELS.reduce<
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const MOCK_AGENT_SERVER_VERSION = "1.23.0";
|
||||
|
||||
// --- Handlers for options/config/settings ---
|
||||
// Uses wildcard "*" prefix to match both relative paths and absolute URLs
|
||||
// (e.g., http://127.0.0.1:8000/api/...) since the code uses absolute URLs
|
||||
@@ -498,7 +500,7 @@ export const SETTINGS_HANDLERS = [
|
||||
HttpResponse.json({
|
||||
uptime: 0,
|
||||
idle_time: 0,
|
||||
version: "1.18.1",
|
||||
version: MOCK_AGENT_SERVER_VERSION,
|
||||
usable_tools: [
|
||||
"terminal",
|
||||
"file_editor",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { isAgentServerVersionError } from "@openhands/typescript-client/clients";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function getWorkspacesUnsupportedMessage(
|
||||
error: unknown,
|
||||
t: TFunction<"openhands">,
|
||||
) {
|
||||
if (!isAgentServerVersionError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return t(I18nKey.HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER, {
|
||||
actualVersion: error.actualVersion,
|
||||
requiredVersion: error.requiredVersion,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user