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:
Graham Neubig
2026-05-23 12:53:53 +00:00
committed by GitHub
co-authored by neubig
parent 27fe2469f2
commit 8cab54e0b1
18 changed files with 344 additions and 70 deletions
+13 -4
View File
@@ -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."
+1 -1
View File
@@ -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
+45 -7
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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)}
+12 -1
View File
@@ -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,
});
}
+24 -15
View File
@@ -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 };
}
+34
View File
@@ -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}} 件のアクションが完了しました",
+3 -1
View File
@@ -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",
+18
View File
@@ -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,
});
}