The SDK build script strips the 'v' prefix from semver release tags when
publishing Docker images. The correct tag format is {version}-python
(e.g., 1.22.0-python), not v{version}-python.
This fixes the 'Unable to find image' error when running npm run dev:docker.
Changes:
- Update DEFAULT_AGENT_SERVER_TAG from v1.22.0-python to 1.22.0-python
- Update documentation in AGENTS.md to reflect correct tag format
Co-authored-by: openhands <openhands@all-hands.dev>
44 KiB
Repository Notes
-
This repository is a near-direct port of the OpenHands frontend, adapted to talk straight to
software-agent-sdk/agent_serverwithout the usual OpenHands app backend. -
Frontend API adaptation lives mainly in
src/api/:option-servicefabricates an OSS web-client config and reads models/providers fromagent_serverLLM endpoints.settings-serviceuses agent server/api/settingsendpoints for persistence; reads schemas from/api/settings/agent-schemaand/api/settings/conversation-schema, fetches settings with optionalX-Expose-Secrets: encryptedheader for conversation start payloads, and saves settings via PATCH with diffs.agent-server-conversation-service,event-service,agent-server-git-service, andskills-serviceare mapped directly toagent_serverREST endpoints.open-hands-axiosinjects the optionalX-Session-API-Keyfrom env/local config for all requests.
-
Supported env vars for deployment:
VITE_BACKEND_BASE_URLfor the agent server base URL.VITE_SESSION_API_KEYfor optional session auth.VITE_WORKING_DIRfor the default workspace path sent when starting conversations.VITE_WORKER_URLSas a comma-separated list of browser worker URLs if you want the Browser tab to probe exposed app hosts.VITE_ENABLE_BROWSER_TOOLS=falseto omitBrowserToolSetfrom new conversation payloads.VITE_LOAD_PUBLIC_SKILLS=falseto disable loading public skills from the OpenHands extensions marketplace (https://github.com/OpenHands/extensions). Defaults to true.
-
Default working-dir fallback is now the relative path
workspace/project(exported asDEFAULT_WORKING_DIRfromsrc/api/agent-server-config.ts); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding/workspace/project. -
The UI keeps most OpenHands routes/layout intact, but hosted-only behavior (org, account management, integrations) has been removed via the fabricated OSS config because there is no separate app backend.
-
Verification command:
npm run typecheck && npm run build. -
GitHub automation now includes
.github/workflows/ci.ymlfornpm ci,npm test, andnpm run build, plus.github/dependabot.ymlwith weekly npm/github-actions updates gated by a 7-day cooldown. -
Direct
dependenciesanddevDependenciesinpackage.jsonare exact-pinned (no caret ranges); reproducible installs should use the committedpackage-lock.jsonplusnpm ci, and targeted transitive fixes still belong inoverrides. -
package-lock.jsonmust also retain the optional peer entry fornode_modules/vite-tsconfig-paths/node_modules/typescript@5.9.3; without that nested lock entry, cleannpm ciinstalls on CI fail withMissing: typescript@5.9.3 from lock file. -
npm testnow runsnpm run make-i18nfirst so clean environments generatesrc/i18n/declaration.tsbefore Vitest loads aliased imports. -
__tests__/vite-config.test.tsshould importvite.configdirectly under// @vitest-environment node; spawning plainnode -e 'import ./vite.config.ts'is not portable across Node patch releases in CI. -
vitest.setup.tsmust guard DOM-specific globals (HTMLCanvasElement,HTMLElement,window) because some suites run in the Node environment instead of jsdom. -
__tests__/components/providers/posthog-wrapper.test.tsxmust wrapPostHogWrapperin aQueryClientProvider; the wrapper now reads its client from React Query context instead of importing the global singleton. -
WebSocket hook regression note:
__tests__/hooks/use-websocket.test.ts'sonClosecallback assertion was flaky against the shared MSW websocket server in CI; keep that single test on a deterministic stubbedWebSocketclose path instead of relying on MSW close timing. -
Library i18n regression note:
__tests__/i18n/library-namespace.test.tsimports../../src/index, which can take >5s under the full Vitest suite aftervi.resetModules(). Keep an explicit per-test timeout (currently 15s) so the suite doesn't fail on slow workers. -
src/components/shared/buttons/styled-tooltip.tsxshould keep HeroUI tooltip animations disabled in Vitest (disableAnimationwhenimport.meta.env.MODE === "test"); otherwise full-suite runs can end with unhandledwindow is not definedrejections fromframer-motionafter jsdom teardown (seen viarecent-conversationtests in CI). -
__tests__/i18n/library-namespace.test.tsimports 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-clientis consumed directly fromgithub:OpenHands/typescript-client#6b9603f; that package ships the needed subpath exports forclient/http-client,events/remote-events-list, andworkspace/remote-workspace.RemoteWorkspace.gitChanges/gitDiffaccept 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. -
Shared TypeScript-client adapters live in
src/api/typescript-client.ts; prefer those helpers for agent-server-backed REST/workspace/event/VS Code calls before falling back toopen-hands-axios. -
Local verification/build gotchas:
npm run typecheckassumes generated translation types exist; runnpm run make-i18nfirst ifsrc/i18n/declaration.tsis missing.
-
Merge note:
mainremoved the old project-management integration subcomponents/hooks and their related feature-flag/i18n surface. If a feature branch still keeps the top-level/integrationsgit-token page, retainsrc/routes/git-settings.tsxplus the git-provider token inputs/hooks, but do not blindly restoresrc/components/features/settings/project-management/*or the old integration mutation/query hooks unless the corresponding option types and i18n keys are also reintroduced. -
The OSS cleanup removed hosted-only auth, org, account, onboarding, and invitation codepaths, routes, and tests. Keep
integrations,git-settings,secrets, MCP settings, and other local/self-hosted flows intact when simplifying OSS behavior. -
When merging main into this branch, keep the new agent-server compatibility bootstrap in
src/root.tsx, but do not reintroduce hosted-only invitation cleanup or marketing CTA chrome in the OSS user menu; the OSS account menu should just render settings links plus Docs. -
During the OSS cleanup audit, the runtime removals held up, but route-level regression coverage for still-active OSS settings pages had been deleted too aggressively. Keep focused tests for local/self-hosted screens like
app-settings,llm-settings,git-settings,mcp-settings, andsecrets-settingseven when stripping hosted-only code. -
npm run dev:mockneeds MSW handlers for the direct agent-server routes used by the adapted frontend, not the original OpenHands mock paths. Key routes that must stay covered are:- bootstrap/model loading:
/server_info,/api/llm/models/verified,/api/llm/providers - settings schemas:
/api/settings/agent-schema,/api/settings/conversation-schema - settings CRUD:
GET /api/settings,PATCH /api/settings - secrets CRUD:
GET /api/settings/secrets(list),GET /api/settings/secrets/:name(value),PUT /api/settings/secrets(upsert),DELETE /api/settings/secrets/:name - conversation browsing/loading:
/api/conversations/search,/api/conversations?ids=...,/api/conversations/:id,/api/conversations/:id/events/* - runtime git panels:
/api/git/changes,/api/git/diff
- bootstrap/model loading:
-
Static mock verification needs a build created with
VITE_MOCK_API=true(usenpm run build:mock); the client must start MSW whenever that flag is enabled, even in production/static builds, otherwise routes like/settingsand the conversations pane fall through to the static server and crash on undefined.filter/.mapassumptions. -
No frontend version guard:
OptionService.getConfig()callsloadAgentServerInfo()which fetches/server_infoonly to (a) detect an unreachable agent server (renders the onboarding screen viaAgentServerUnavailableError) and (b) cacheusable_toolsfor tool gating. All advertised versions are accepted. The "manage backends" modal displays each local backend's/server_info.versionin light text via theBACKEND$VERSION_LABELtranslation key. -
Backend registry: there is no longer a separate "bundled" backend. On first read of the
openhands-backendslocalStorage key (raw === null),readStoredBackends()seeds the registry with one default local backend (makeDefaultLocalBackend(), idBUNDLED_BACKEND_ID = "default-local", host/api-key fromagent-server-config). After that the seed is just an ordinary registered backend — users can rename or remove it like any other.getEffectiveLocalBackend()returns the first registered local, falling back to a synthesized default if the registry has no locals (used by API clients that need a baselinelocaltarget). The "Manage backends" modal and the BackendSelector dropdown both read from the single registered list, so the seeded default appears in both without any special-casing. -
Shared
Dropdownopen behavior: when the menu opens, it clears the input/search text so callers can show the current selection viaplaceholderwhile still rendering the full option list. Generic dropdown tests should not expect the selected label to remain in the input after reopening unless the parent explicitly controls that display. -
useLoadOlderEventsneeds ref-basedisLoading/hasMoreguards in addition to React state becauseChatInterfacecan trigger pagination fromonScroll,onWheel, and the no-overflow effect in the same tick; closure-based state alone allows duplicate page requests. -
ChatInterfacecontinuity tests should assert that conversation messages render without the fullchat-messages-skeleton, not thatdata-testid="loading-spinner"is absent: the lazy older-events indicator reuses the sharedLoadingSpinnercomponent and legitimately renders that inner test id while history backfill is running. -
useConversationHistorynow mirrors the older-events pagination fallback when the first page is exactlyINITIAL_HISTORY_PAGE_SIZE: treatnext_page_idor a full page ashasMore, so older agent-server variants that omitnext_page_idstill allow one more backfill request. The hook anduseLoadOlderEventsalso defensively reject mocked/malformedpage.itemsresponses before reversing them. -
/server_infotool capability metadata fromsoftware-agent-sdkPR #3028 ended up shipping asusable_tools(notavailable_tools). Frontend browser-tool gating should key offusable_tools, and still default to allowing tools when the server does not advertise tool metadata. -
Useful regression tests for mock mode live in
__tests__/api/option-service.test.ts,__tests__/api/mock-conversation-handlers.test.ts, and__tests__/api/mock-settings-handlers.test.ts. -
Browser-verified mock-mode tour artifact was generated at
artifacts/frontend-tour.gif. -
Live
agent_servercompatibility quirks discovered during browser verification:- Latest
openhands-agent-serverlive-mode notes (verified against 1.18.1):/api/settings/agent-schemaand/api/settings/conversation-schemaexist on recent servers, but they return401when the server was started withSESSION_API_KEYorOH_SESSION_API_KEYS_0; the frontend must send the same value asVITE_SESSION_API_KEY/X-Session-API-Key.- The provider/model picker should use
/api/llm/providers,/api/llm/models, and/api/llm/models/verified;/api/v1/config/providers/searchand/api/v1/config/models/search404 on current live agent-server releases. - When the browser is accessing the frontend through a remote host (for example an All Hands work URL) but
VITE_BACKEND_BASE_URLpoints at127.0.0.1/localhost, browser-side REST calls must fall back to the frontend origin so Vite can proxy/apiand/socketsto the local backend.
GET /api/conversationsexpects repeatedidsparams (?ids=a&ids=b), not Axios's default bracket form (ids[]=a), so the shared Axios client needs a custom params serializer.- Runtime git panels should prefer the conversation's reported
workspace.working_dirwhen present; falling back to/workspace/projectcan produce 500s likeNot a git repositoryfor direct local workspaces such as/workspace/project/agent-canvas. - For development,
npm run devnow usesuvxto run a temporary agent-server installation, so no permanentuv tool installis required. For standalone installations,uv tool install -U --with openhands-tools --with openhands-workspace openhands-agent-serverwould expose the executable asagent-server(notopenhands-agent-server), possibly requiring~/.local/binonPATH. - Current SDK / agent-server conversation start payloads must use SDK-registered snake_case tool names, not the old class-style names. Working names against SDK v1.18.1 were:
terminalfile_editortask_trackerbrowser_tool_setUsingTerminalTool/FileEditorTool/TaskTrackerTool/BrowserToolSetcaused live/api/conversations/{id}/eventsruns to fail withToolDefinition '<name>' is not registered.
- The root compatibility bootstrap now treats
/server_infonetwork/timeout failures as a first-classAgentServerUnavailableError, uses a short 5s timeout for that probe, and disables React Query retries/toasts for the initial config fetch so missing backends fail fast with an explicit full-screen notice. - For local verification in this repo, setting
VITE_WORKING_DIR=/workspace/project/agent-canvasavoids initial Changes-tab 500s from pointing conversations at the non-repo parent/workspace/project. - A successful end-to-end live run in this environment required a real LLM config (
LLM_MODEL+LLM_API_KEY). The defaultlitellm_proxy/...model with nollm_api_keyfailed at runtime with alitellm.AuthenticationError.
- Latest
-
Agent-server recovery UX gotchas:
- Keep
/settings/agent-serverin the intermediate-page bypass path (use-is-on-intermediate-page) souseConfig()-driven layout/sidebar queries do not block the recovery screen behind a global spinner. PostHogWrappershould treat config-fetch failures as silent/optional (no user-facing toast), otherwise onboarding/recovery screens show a duplicate incompatible-server toast on top of the friendly guidance.- Keep the settings route on the compact
AgentServerConnectionFormvariant withshowSectionHeader={false}and no checklist; the blocked root onboarding should stay similarly minimal, with only the status card plus a single sentence that links to the repo setup instructions. - For local screenshot/GIF capture of SPA routes, serve
build/with an SPA fallback (for examplesirv build --single) and restart the static server after each rebuild so hashed asset URLs stay in sync.
- Keep
-
Git provider token persistence note:
src/api/secrets-service.tsstores git provider tokens in TWO places:- Agent-server secrets API (
PUT /api/settings/secrets) with naming conventionGIT_PROVIDER_{PROVIDER}_TOKEN- for agent runtime use - localStorage (
openhands-agent-server-git-provider-tokens) - for frontend git API calls (repo search, branches, etc.) TheaddGitProvidermethod stores to server FIRST (must succeed), then updates localStorage. This ensures server-side persistence is the source of truth.
- Agent-server secrets API (
-
Agent server connection settings now live at
Settings > Agent Server(/settings/agent-server). The page reads deployment defaults fromVITE_BACKEND_BASE_URL/VITE_SESSION_API_KEY, saves user overrides in theopenhands-agent-server-configlocalStorage key, and must stay reachable even when the backend compatibility probe fails so users can recover from missing or wrong backend configuration. -
Backend/footer actions that launch modals from inside a dropdown or popover should intercept
onMouseDownto keep the menu mounted, then perform the actual open ononClick. Current examples:Add backend/Manage backendsinsrc/components/features/backends/backend-selector.tsx, plus the mirrored workspace-footer buttons insrc/components/features/conversation-panel/new-conversation-button.tsx. -
BackendSelector's cloud-org switch paths should never rethrow from the dropdownonChangehandler: unexpected non-Axios failures need a generic error toast instead of an unhandled promise rejection, and the malformed(cloud backend, null org)self-heal path should fall back to the bundled backend if/switchfails. -
NewConversationButtonshould support keyboard dismissal (Escape) for its inline popover, while still keeping the popover open when its modal children (FolderBrowserModal,ManageWorkspacesModal) are active. -
README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real
openhands-agent-server(clone, install uv, optional.env, runnpm run dev). -
Keep README user-focused and move contributor/developer-specific workflows (
dev:safe, mock mode, detailed env vars/build-test notes) intoDEVELOPMENT.md. -
scripts/dev-safe.mjsusesuvxfor temporary agent-server installation — no permanentuv tool installneeded. Environment variables (highest precedence first):OH_AGENT_SERVER_LOCAL_PATH— absolute path to a localsoftware-agent-sdkcheckout. Runs the local checkout viauvxwith--with-editableforopenhands-sdk/openhands-tools/openhands-workspaceand--reinstallforopenhands-agent-server, so SDK edits are picked up on restart. Highest precedence.OH_AGENT_SERVER_GIT_REF— git commit SHA or branch name (takes precedence over version)OH_AGENT_SERVER_VERSION— specific PyPI version (e.g., "1.22.0")OH_SECRET_KEY— secret key for settings encryption; uses a static default for local dev since it's needed for reading persisted encrypted values across restartsSESSION_API_KEY/OH_SESSION_API_KEYS_0/VITE_SESSION_API_KEY— session API key for agent-server authentication; auto-generated usingcrypto.randomBytes(32)if not set, passed to both agent-server (OH_SESSION_API_KEYS_0) and frontend (VITE_SESSION_API_KEY)- Default: released PyPI version
1.22.0for agent-server SDK libraries
-
scripts/dev-docker.mjsruns the agent-server inside a Docker container instead of viauvx. The default image uses versioned release tags:DEFAULT_AGENT_SERVER_TAG— uses format{version}-python(e.g.,1.22.0-python) for reproducibility. Note: the SDK build script strips the "v" prefix from semver release tags.- Should stay in sync with
DEFAULT_AGENT_SERVER_VERSIONindev-safe.mjsfor consistency between Docker and non-Docker dev modes OH_AGENT_SERVER_GIT_REF— override to use a git ref-based tag (e.g.,main→main-python,abc1234→abc1234-python)- Docker images are published from https://github.com/OpenHands/software-agent-sdk via the Agent Server workflow to
ghcr.io/openhands/agent-server
-
Security: Both
scripts/dev-safe.mjsandscripts/dev-with-automation.mjsauto-generate random API keys on each startup for better security isolation:SESSION_API_KEY— 64-character hex (256-bit) for agent-server API authentication; auto-generated per session unless overridden via env varAUTOMATION_LOCAL_API_KEY— 64-character hex for automation backend auth; auto-generated per session unless overriddenOH_SECRET_KEY— kept as a static default because it's used for encrypting/decrypting persisted settings values and needs consistency across restarts
-
scripts/dev-safe.mjsshould fail fast ifuvxcannot be spawned (for example missing PATH entries). -
npm run devnow runs the full stack with automation by default (viadev:automation). Usenpm run dev:minimalfor agent-server + Vite only. -
scripts/dev-with-automation.mjsruns the full stack: agent-server, automation backend (both via uvx), Vite dev server, and ingress proxy. Uses a standalone ingress proxy (scripts/ingress.mjs) to route traffic:/api/automation/*→ automation backend (:18001)/api/*,/sockets, etc. → agent server (:18000)/*(default) → Vite dev server (:3001)- Environment variables:
PORT(ingress port, default: 8000),OH_AUTOMATION_GIT_REF(git ref, overrides default version),OH_AUTOMATION_VERSION(default:1.0.0a2),AUTOMATION_LOCAL_API_KEY(optional, use a fixed key; default: auto-generated random key per session) - Access points:
http://localhost:8000/(main UI),http://localhost:8000/api/automation/docs(API docs) - Security:
AUTOMATION_LOCAL_API_KEYis auto-generated usingcrypto.randomBytes(32)on each startup for better security isolation. Set the env var explicitly to use a consistent key across restarts. The cipher key (OH_SECRET_KEY) keeps a static default for local dev since it's used for encrypting/decrypting persisted settings values.
-
scripts/ingress.mjsis a standalone HTTP reverse proxy that can be used independently to route traffic to multiple backends based on URL path prefix. -
scripts/dev-safe.mjs(nownpm run dev:minimal) runs just agent-server + Vite without automation. -
Vite dev mode can black-screen on first load with
504 Outdated Optimize Depif core client-entry deps are not prebundled; keepreact,react/jsx-runtime,react-dom/client, andreact-router/dominoptimizeDeps.include. -
Bundle/dev-graph hygiene (Tier 1 cleanup landed):
src/i18n/translation.json(~1 MB) is imported only bysrc/i18n/resources.ts, whichsrc/i18n/index.tsre-exports astranslationResourcesfor the@openhands/agent-canvas/i18nsubpath. The re-export is aexport … fromplus/* @__PURE__ */annotation, so rollup drops the JSON from the app build (the prodcustom-toast-handlerschunk went from ~909 KB to ~74 KB). Do not move the JSON import back intosrc/i18n/index.ts— that immediately re-bundles all translations into every chunk that importsi18n.- The environment-switch overlay is split: lightweight store/triggers live in
components/features/backends/environment-switch-store.ts; the React component lives inenvironment-switch-overlay.tsx(re-exports the store API for back-compat). Eagerly-mounted callers (e.g.backend-selector.tsx) MUST import trigger helpers from the store, not the overlay file. The overlay isReact.lazy'd fromroutes/root-layout.tsx. - Other always-conditional UI is
React.lazy'd to keep the root layout's eager graph small:AnalyticsConsentFormModal,AlertBanner(root-layout),SettingsModal(sidebar),AgentServerConnectionForm(root.tsx). Tests that assert on these mounted nodes needawait screen.findByTestId(...)instead ofgetByTestId(...). - The terminal tab (
components/features/terminal/terminal.tsx) isReact.lazy'd inconversation-tab-content.tsxalongside the other tabs, so xterm + addon-fit + xterm.css don't enter the conversation route's eager graph. - Avoid importing app code through
#/components/conversation-events/chator itsevent-message-components/index.tsbarrel — they exist forlib/index.ts(npm subpath) consumers only. Internal callers use deep paths (./messages,./event-message-components/<name>,./event-content-helpers/should-render-event) so Vite dev doesn't fan out the barrel.
-
Vercel deployment note: React Router builds for this repo must keep
build/clientintact on actual Vercel builds and includepresets: [vercelPreset()]from@vercel/react-router/vite; flatteningbuild/clientduring a Vercel build produces deployments with empty outputs (routes: null, no static files) and a production 404. -
The repo should include a root
LICENSEfile to satisfy the incubator-program requirements. -
OpenHands repo bootstrap files live under
.openhands/:.openhands/setup.shinstallsuv(viacurl -LsSf https://astral.sh/uv/install.sh | sh) if not present, installs frontend dependencies withnpm ciwhen needed, creates.envfrom.env.sampleif missing, appendsVITE_WORKING_DIRfor this repo when unset, and generatessrc/i18n/declaration.tsvianpm run make-i18n..openhands/hooks.jsonregisters.openhands/hooks/on_stop.shas a Stop hook so OpenHands runs the local quality gate (npm run lintandnpm test) before finishing.
-
GitHub PR-review automation should stay aligned with the current OpenHands repo conventions: keep the review workflow at
.github/workflows/pr-review-by-openhands.yml, keep the companion.github/workflows/pr-review-evaluation.yml, auto-run on newly opened non-draft PRs andready_for_reviewevents from established contributors, still support thereview-thislabel /openhands-agent/all-hands-botreviewer triggers, use the OpenHands app LLM proxy defaults, and use the dual-trigger pattern (pull_requestfor same-repo PRs,pull_request_targetfor forks) so workflow changes can self-verify without widening fork secret exposure. -
The repo now includes
.agents/skills/custom-codereview-guide.md, adapted fromOpenHands/software-agent-sdk, to force PR reviews to always leave either an APPROVE or COMMENT review instead of silently finishing with no review object. -
HeroUI rollback / migration notes:
- The attempted HeroUI v3 upgrade changed global theme wiring and homepage design tokens enough that the repo currently prefers
@heroui/react@2.8.10until a broader visual validation pass is done. - Keep the v2 Tailwind integration active via
@plugin '../hero.ts'insrc/tailwind.cssand source HeroUI classes fromnode_modules/@heroui/theme/dist/**/*. - The settings UI currently relies on the v2
Autocomplete+AutocompleteItem/AutocompleteSectionAPIs insettings-dropdown-input.tsxandmodel-selector.tsx; a future v3 retry will need to replace those controls again.
- The attempted HeroUI v3 upgrade changed global theme wiring and homepage design tokens enough that the repo currently prefers
-
Library i18n is now namespace-scoped under
openhands:src/i18n/index.tsexportsOPENHANDS_I18N_NAMESPACE,translationResources, andwaitForI18n(),scripts/make-i18n-translations.cjsemitspublic/locales/<lang>/openhands.json, standalonesrc/entry.client.tsxexplicitly awaits i18n init, and host apps can register bundles via the@openhands/agent-canvas/i18nsubpath export. -
Route decoupling note:
src/components/should stay free of directreact-routerimports. Route state now flows throughsrc/context/navigation-context.tsx, the standalone app bridges router state withsrc/routes/react-router-navigation-provider.tsx, and link-like UI should usesrc/components/shared/navigation-link.tsx. -
Test helper note:
test-utils.tsxnow wraps renders with a defaultNavigationProvider(currentPath: "/",conversationId: "test-conversation-id"). Navigation-sensitive tests can override that viarenderWithProviders(..., { navigation: { ... } }). -
CSS isolation for embeddable/hosted use now relies on a scoped wrapper attribute: all bundled CSS is prefixed under
[data-agent-server-ui]viapostcss-prefix-selectorinvite.config.ts, with selector exceptions handled bytransformAgentServerUISelector()insrc/styles/agent-server-ui-style-scope.ts. That transform must remap global selectors like:root,html, andbodydirectly onto the scoped shell instead of emitting impossible descendants such as[data-agent-server-ui] :root. -
Public embedding entry points should use
AgentServerUIProviders(scoped root on by default) orAgentServerUIRootfor manual control. The standalone app already renders its own scoped root insrc/root.tsx, sosrc/entry.client.tsxmust passwithStyleRoot={false}to avoid nesting duplicate shells. KeepAgentServerUIRootand the scoping constants re-exported fromsrc/lib/index.tsso library consumers can customize the host wrapper without reaching into private paths. -
AgentServerUIRoot's themed inner wrapper must set a defaultcolor: var(--foreground)in addition to thedark/data-thememarkers; otherwise inherited text andcurrentColorSVG icons fall back to dark browser defaults after CSS scoping, causing dark-on-dark regressions on pages like the home screen. -
Theme/customization tokens for the embedded shell are exposed as
--oh-*CSS variables. Override them throughstyleOverrides,style, or host CSS targeting[data-agent-server-ui]; Tailwind theme tokens insrc/tailwind.cssshould continue to reference those variables with@theme inlineso host apps can restyle the UI without reworking component class names. -
Regression coverage for the CSS isolation work lives in
__tests__/agent-server-ui-providers.test.tsx,__tests__/agent-server-ui-style-scope.test.ts, and the browser-leveltests/css-isolation.spec.tsPlaywright test. -
Conversation history is loaded lazily, REST-first then WebSocket:
-
useConversationHistory(insrc/hooks/query/use-conversation-history.ts) fetches only the most recentINITIAL_HISTORY_PAGE_SIZE(default 50) events usingsort_order='TIMESTAMP_DESC', then reverses to chronological order. Older pages are paginated in viauseLoadOlderEventswhen the user scrolls near the top of the chat. -
EventService.searchEvents(conversationId, conversationUrl, sessionApiKey, options)returns the rawEventSearchPage({ items, next_page_id }); options supportlimit,pageId,sortOrder,timestampGte,timestampLt. Both the local and cloud-proxy code paths forward the new params. -
The main
ConversationWebSocketProviderwaits for the REST query to settle before opening its socket, then connects withresend_mode='since'andafter_timestamp=<latest preloaded event ts>(falling back to'all'when the REST result is empty or errored). The legacyresend_all=trueflag is removed for the main connection; the planning-agent sub-conversation still usesresend_alluntil it is migrated to the same REST-then-WS pattern. -
The event store gained a bulk
addEvents(events)action (used for the initial REST seed and for "scroll-up" pagination) that re-sorts by timestamp once at the end so older pages can be merged in cheaply. Per-event dedup still works via the existingeventIdsset. -
ChatInterfacewiresuseLoadOlderEventsinto its scroll handler (threshold 80px from the top), shows adata-testid="loading-older-events"spinner during pagination, and preserves the visible scroll offset by storing the previousscrollHeightand adding the height delta after the older page renders. -
useLoadOlderEventsintentionally distinguishes between "no anchor yet" (empty store before the initial REST seed, soloadOlder()should no-op) and "malformed oldest event" (store has an oldest event with no timestamp, so the hook throws, flipshasMorefalse, andChatInterfacesurfaces the failure via the shared error banner instead of failing silently).
-
-
Action grouping in the chat stream:
src/components/conversation-events/chat/group-events.tsfolds runs of consecutive groupable events (regularActionEvent/ObservationEventcards, but notFinishAction,ThinkAction,PlanningFileEditorObservation,TaskTrackerObservation, hooks, errors, or message events) into singleRenderedItemgroups. The threshold lives inEVENT_GROUP_MIN_SIZE(currently 2, so even pairs of back-to-back actions get folded).EventGroup(src/components/conversation-events/chat/event-message-components/event-group.tsx) is the collapsible header that wraps each run. Default state is collapsed; the header showsEVENT_GROUP$ACTIONS_COMPLETED(with a success check) when the group is done, orEVENT_GROUP$ACTIONS_PROGRESSplus the currently-running action's title (fromgetEventContent) while a memberActionEventhas not yet been replaced by its observation in the UI events array. Expanding renders the originalEventMessages verbatim so each card still expands the way it did before.- Agent thoughts attached to an
ActionEvent(event.thought) are hoisted out of groups:groupEventsemits a thirdRenderedItemkind"thought"whenever a groupable event carries (or, for an observation, originates from) a non-empty thought, flushing the current run and starting a new one.messages.tsxrenders that item viaThoughtEventMessageand passessuppressThoughttoEventMessageso the inline thought isn't duplicated inside the group's expanded content.ThinkActionis excluded from this hoisting because the thought IS its action body and is rendered through its own codepath. groupEventsnow de-duplicates hoisted thoughts by action ID so mixed UI arrays that temporarily contain both an action and its replacement observation do not emit the same thought twice;minSizeis treated as a validated internal invariant (>= 1).EventGroupshould returnnullfor an emptyeventsarray and wire the toggle button to the expanded body witharia-controls/role="region"/aria-labelledby.src/components/conversation-events/chat/messages.tsxis the only consumer; the grouping is transparent to upstream code. Coverage lives in__tests__/components/conversation-events/chat/group-events.test.ts(pure logic, including thought hoisting) and__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx(rendering/interaction).
-
Home page workspace UX (agent-server backend):
RepoConnectorno longer renders a tabbed launcher; it just rendersWorkspaceSelectionFormbecause this build only ever talks to an agent-server backend (no cloud backend is wired up). The oldLaunchTabscomponent was removed; if a cloud backend is ever supported again, branch on backend mode inRepoConnectorand renderRepositorySelectionFormfor that path.FolderBrowserModal's "Use this folder" button adds only the currently navigated directory as a single workspace (named by its basename). It no longer iteratessubdirsand adds each child as a separate workspace.- The
WorkspaceDropdownsticky footer now exposes both "+ Add Workspace" (opens the folder browser) and "Manage Workspaces" (opensManageWorkspacesModal, which lets users remove individual workspaces viauseWorkspacesStore.removeWorkspace). The Manage button is hidden when there are no workspaces yet. - The sidebar "+ New Conversation" trigger (
NewConversationButtoninsrc/components/features/conversation-panel/) opens a popover that is a flat list, not the home-screen combobox: a leading "No workspace" entry plus one entry per stored workspace, each clicking through touseCreateConversationimmediately (no separate Launch button). It mirrors the dropdown footer actions/pattern (+ Add Workspace,Manage Workspaces) locally rather than embeddingWorkspaceDropdownitself. useResolvedWorkspaces()now returnsisLoading/isErrorfor parent-directory scans;WorkspaceSelectionFormshould surface that state (status text and disabling the empty dropdown while parent results are still loading) instead of assuming the merged list is immediately ready.ManageWorkspacesModalshould require a confirmation step before removing either a saved workspace or a workspace parent; parent removals should mention the child-workspace impact, and tests should assert both the confirmation flow and that removing the selected workspace clears the launch selection.- In
useWorkspacesStore, keepclearWorkspaces()scoped to literal workspaces only; use explicit helpers likeclearWorkspaceParents()/clearAll()for broader resets so future callers do not accidentally wipe parent registrations.
-
Custom secrets are NOT auto-attached by the agent-server.
POST /api/conversationsonly persists what the client sends inrequest.secrets; the persisted secrets store (/api/settings/secrets) is never read at conversation-start.buildStartConversationRequestWithEncryptedSettingsenumeratesSecretsService.getSecrets()and turns each entry into aLookupSecretwhoseurlpoints back at/api/settings/secrets/{name}and whoseheaderscarryX-Session-API-Keyfor auth. Pre-1.21.x agent-server SDKs would silently drop that header during validation whensecrets_encrypted=true(the cipher in the validation context tried tocipher.decrypt(plaintext_session_key), failed, and the validator removed the header — the conversation runtime then got 401s for every saved secret). The SDK fix preserves plaintext header values when decryption fails; if you still see saved secrets unavailable inside a conversation, verify the running agent-server bundles aLookupSecret._validate_secretsthat falls back to plaintext on decrypt failure. -
Library packaging notes:
- Public npm entrypoints now come from
src/index.ts→src/lib/index.ts, with domain barrels undersrc/components/{conversation,terminal,browser,files,settings,sidebar}/index.ts. npm run buildremains the standalone app build (react-router build), whilenpm run build:librunsvite buildin library mode plustsc -p tsconfig.lib.jsonto emit.d.tsfiles intodist/.- The library build relies on
vite.config.tswithBUILD_LIB=true, preserved modules indist/, and packageexportsentries that map root/subpaths todist/**/*.jsplus matching declaration files. - Declaration emit needs
src/library-env.d.tsand the narrowedtsconfig.lib.json; broadsrc/**/*.tsxdeclaration builds pulled in route-only files and missed?react/window globals.
- Public npm entrypoints now come from
-
Bundle/dev-graph hygiene (Tier 1 cleanup landed):
src/i18n/translation.json(~1 MB) is imported only bysrc/i18n/resources.ts, whichsrc/i18n/index.tsre-exports astranslationResourcesfor the@openhands/agent-canvas/i18nsubpath. The re-export is aexport … fromplus/* @__PURE__ */annotation, so rollup drops the JSON from the app build (prodcustom-toast-handlerschunk: 909 KB -> 74 KB;conversationchunk: 728 KB -> 392 KB). Do not move the JSON import back intosrc/i18n/index.ts— that immediately re-bundles all translations into every chunk that importsi18n.- The environment-switch overlay is split: lightweight store/triggers live in
components/features/backends/environment-switch-store.ts; the React component lives inenvironment-switch-overlay.tsx(re-exports the store API for back-compat). Eagerly-mounted callers (e.g.backend-selector.tsx) MUST import trigger helpers from the store, not the overlay file. The overlay isReact.lazy'd fromroutes/root-layout.tsx. - Other always-conditional UI is
React.lazy'd to keep the root layout's eager graph small:AnalyticsConsentFormModal,AlertBanner(root-layout),SettingsModal(sidebar), and the unreachable-backend modal path inroot.tsx(ManageBackendsModal). Tests that assert on these mounted nodes may needawait screen.findByTestId(...)/waitFor(...)instead of synchronousgetByTestId(...). - The terminal tab (
components/features/terminal/terminal.tsx) isReact.lazy'd inconversation-tab-content.tsxalongside the other tabs, so xterm + addon-fit + xterm.css don't enter the conversation route's eager graph (they ship as a separateterminal-*.jschunk now). - Avoid importing app code through
#/components/conversation-events/chator itsevent-message-components/index.tsbarrel — they exist forlib/index.ts(npm subpath) consumers only. Internal callers use deep paths (./messages,./event-message-components/<name>,./event-content-helpers/should-render-event) so Vite dev doesn't fan out the barrel.
-
Backend dropdown connectivity indicator:
useBackendsHealth(src/hooks/query/use-backends-health.ts) polls each registered backend every 10s. Local agent-server backends are probed viaServerClient.getServerInfo()(/server_info); cloud SaaS backends are probed viagetCurrentCloudApiKey()(/api/keys/currentthrough the bundled/api/cloud-proxy). Verdicts are surfaced as a colored dot rendered throughDropdownOption.prefix(added tosrc/ui/dropdown/types.ts); the trigger reads its prefix from the liveoptionsarray (not downshift's frozenselectedItem) so the indicator updates without remounting. The same dot is also rendered in each row ofManageBackendsModal. Tests live in__tests__/hooks/query/use-backends-health.test.tsx, theconnection indicatorblock of__tests__/components/backends/backend-selector.test.tsx, and__tests__/components/backends/manage-backends-modal.test.tsx. -
Manage Backends modal:
src/components/features/backends/manage-backends-modal.tsxlets users edit (host/name/api-key/kind) and remove existing backends, plus add new ones inline via a "+ Add Backend" footer button that opens aBackendFormModal. Both the dropdown footer's "Add backend" and the manage modal's "+ Add Backend" reuseBackendFormModal(seebackend-form-modal.tsx), withmode="add"ormode="edit";AddBackendModalis now a thin compatibility wrapper forBackendFormModal mode="add". The modal is also auto-rendered (with a no-oponClose) bysrc/root.tsxwhen the active backend is unreachable, replacing the old full-screenMissingAgentServerNoticeonboarding screen. -
Conversation right-panel regression note:
ConversationTabsnow owns the moved refresh/build buttons, so__tests__/components/features/conversation/conversation-tabs.test.tsxshould cover that behavior directly. In those tests, seed both the persisted conversation-state key and the Zustand store (selectedTab,isRightPanelShown,hasRightPanelToggled) — the component sync effect currently restoreshasRightPanelToggledfrom localStorage, notisRightPanelShown, so localStorage alone will not make a tab read as active. -
Changes tab /
FileDiffViewerdeleted-file note: the agent-server's/api/git/diffendpoint callspath.exists()first (seeopenhands-sdk/openhands/sdk/git/git_diff.py→get_git_diff), so requesting a diff for aD(deleted) file returnsGitPathError→ HTTP 400 and trips the global QueryCache error toast.useUnifiedGitDiffdisables the query whentype === "D"andFileDiffViewerrenders a localized "file deleted" placeholder (DIFF_VIEWER$FILE_DELETED,data-testid="file-deleted-message") instead of the view-mode toolbar / Monaco editor for that case. -
Onboarding modal:
src/components/features/onboarding/onboarding-modal.tsxis a 4-step welcome flow rendered by<OnboardingHost />(mounted on the home route) and gated by theopenhands-onboardedlocalStorage flag (use-onboarding-completion.ts). The four steps live understeps/: choose-agent (Step 0 – OpenHands selectable, Claude Code & Codex disabled with a "coming soon" note), check-backend (embeds the newBackendFormextracted frombackend-form-modal.tsxplus a colored connection banner driven byuseBackendsHealth), setup-llm (renders<LlmSettingsScreen onSaveSuccess={onNext} />so the existing settings UI keeps owning validation), and say-hello (text input pre-filled fromONBOARDING$HELLO_DEFAULT_MESSAGE, launches a no-workspace conversation viauseCreateConversationand closes the modal). Animation: all four panels are mounted as siblings inside a horizontal rail; advancing/retreating just setscurrentStep, which translates the rail by-(step * 100)%for the slide effect. Progress is rendered byOnboardingProgressBarwithdata-stateper segment (completed|current|upcoming). When extending, refactorBackendFormModalcarefully — the innerBackendFormis the public surface used both by the modal and byCheckBackendStep; the modal version still owns dirty/save tracking so it keeps "Save"/"Cancel" footer behavior. -
Worktree policy (this conversation): commits are made on the worktree branch and the user expects the worktree to stay attached to that branch. Do NOT run
git switch --detachin the worktree and reattach the branch to the main workspace after each commit — only do that when the user explicitly asks. See~/.openhands/skills/worktree-switch/SKILL.mdfor the manual procedure the user invokes.