Commit Graph
7279 Commits
Author SHA1 Message Date
Rohit Malhotraandopenhands f4dbfcdf8f fix: correct Docker image tag format (remove v prefix) (#339)
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>
2026-05-11 16:27:49 -04:00
Rohit Malhotraandopenhands c0bc27c0ca fix: use versioned release tags for agent-server Docker images (#338)
Update DEFAULT_AGENT_SERVER_TAG in dev-docker.mjs from commit-based tag
(0924962-python) to versioned release tag (v1.22.0-python) for better
reproducibility and consistency with the PyPI version used in dev-safe.mjs.

Changes:
- Update DEFAULT_AGENT_SERVER_TAG to v1.22.0-python
- Add documentation in AGENTS.md explaining the versioning approach
- Document that Docker and non-Docker dev modes should use matching versions

The software-agent-sdk repository builds Docker images with versioned tags
in the format v{version}-python when release tags are pushed, which makes
them suitable for pinning to specific releases.

Closes #323

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-11 15:49:57 -04:00
Rohit Malhotraandopenhands 773cc72324 feat: update SDK to 1.22.0 and add CI version sync check (#333)
* feat: update SDK to 1.22.0 and add CI version sync check

- Update DEFAULT_AGENT_SERVER_VERSION from 1.21.1 to 1.22.0 in dev-safe.mjs
- Update SDK version references in AGENTS.md
- Add scripts/check-sdk-version-sync.mjs to verify automation project uses
  matching SDK versions for openhands-sdk, openhands-tools, openhands-workspace,
  and openhands-agent-server
- Add .github/workflows/sdk-version-sync.yml CI workflow with:
  - Path-filtered PR/push triggers for version-related file changes
  - repository_dispatch triggers (sdk-version-check, sdk-release) for
    external repos to notify when SDK deps change
  - workflow_dispatch with optional version override
  - Scheduled runs every 6 hours to catch upstream changes
  - PyPI version checking support (--check-pypi flag)

The check script supports:
- EXPECTED_SDK_VERSION env var override for CI triggers
- --check-pypi flag to also display latest PyPI versions
- --help for usage documentation

To trigger from external repos (e.g., OpenHands/automation or SDK repo):
  curl -X POST -H "Authorization: token \$GITHUB_TOKEN" \\
    https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\
    -d '{"event_type": "sdk-version-check"}'

* fix: check released PyPI version instead of GitHub main branch

The SDK version sync check now fetches dependencies from the released
openhands-automation package on PyPI (version specified by
DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs) rather than
fetching pyproject.toml from the GitHub main branch.

This ensures we're checking the actual released version that users
would install, not the development version on main.

* fix: address review feedback for SDK version sync check

- Add env var overrides for automation package name and version
- Add retry logic with exponential backoff for PyPI API failures
- Add semantic version normalization for comparing versions
- Fix repository_dispatch to use client_payload.version
- Improve regex to handle parenthesized dependency formats
- Add comprehensive test coverage for helper functions

* fix: add type casts for dynamic module import in tests

* chore: update automation version to 1.0.0a2

- Update DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs
- Update AGENTS.md documentation
- Update test expectation

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-11 15:39:17 -04:00
Hiep Le 7a03d234e9 fix: render full YAML metadata on the Skills page with search & filters (#336) 2026-05-12 02:32:19 +07:00
Vasco Schiavoandhieptl 9106bc30da fix: show folder name for local-only repos in git control bar (#322)
* fix: show folder name for local-only repos in git control bar

* fix

---------

Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-05-12 01:24:45 +07:00
Hiep Le b6529577b8 fix: preserve per_turn_token when combining metrics from a dormant condenser LLM (#332) 2026-05-12 01:24:35 +07:00
Xingyao Wangandopenhands 18e51fa84d fix: move TMUX_TMPDIR to /tmp to avoid socket errors on mounted volumes (#325)
* fix: move TMUX_TMPDIR to /tmp to avoid socket errors on mounted volumes

Some filesystems (NFS, CIFS, certain FUSE/overlay mounts used by Docker
bind-mounts) do not support Unix domain sockets. When TMUX_TMPDIR pointed
to ~/.openhands/agent-canvas/tmux/ inside a container, tmux failed with:

  error connecting to .../tmux-10001/openhands (Operation not supported)

Move tmux socket directory to /tmp/openhands-agent-canvas-tmux which is
always on a local/tmpfs filesystem that supports Unix sockets. Tmux
sockets are ephemeral and don't need persistence across restarts.

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor: drop explicit TMUX_TMPDIR from dev-docker.mjs, use system default

Per review feedback — the container's default TMUX_TMPDIR (/tmp) already
supports Unix domain sockets, so there's no need to set it explicitly.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-11 13:37:25 -04:00
Rohit Malhotraandopenhands 7d9e6ab6ba fix: improve right panel UX when unpinning active tab (#328)
* fix: improve right panel UX when unpinning active tab

- Add RightPanelToggle button in chat header for persistent panel visibility control
- When unpinning the active tab, switch to another pinned tab instead of hiding the panel
- Add i18n keys for show/hide panel tooltips
- Update tests to reflect new behavior

Fixes #326

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: address review feedback

- Fix fragile test pattern using unmount/remount instead of double render
- Show panel toggle on mobile devices as well (remove lg:block restriction)

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-11 13:32:18 -04:00
Robert Brennan 9b2f0d6ff8 Update README.md 2026-05-11 10:20:33 -07:00
Robert Brennan 4e5acbca6c Change caution to warning in SELF_HOSTING.md
Updated caution note to a warning in the self-hosting guide.
2026-05-11 08:03:24 -07:00
Robert Brennan 0740d20c87 Update SELF_HOSTING.md 2026-05-11 08:03:12 -07:00
Hiep Le 2212906fd5 fix(frontend): make Add Workspace modal dynamic and host-aware (#306)
* fix: make Add Workspace modal dynamic and host-aware

* fix: make OH_MOUNT_HOST_HOME opt-in and surface it from the modal
2026-05-11 19:57:56 +07:00
Hiep Le ccefb6cb04 fix: persist app-level settings from /settings/app save flow (#304) 2026-05-11 18:42:25 +07:00
Hiep Le 4acc38da4d fix: wrap description in badge to match LLM Settings styling (#303) 2026-05-11 16:53:05 +07:00
Hiep Le d3707e045e fix: animate sidebar min-width so expand transition is visible (#301) 2026-05-11 16:46:34 +07:00
Hiep Le 173f08e42d fix: cap modal height and scroll LLM settings internally (#299) 2026-05-11 16:08:10 +07:00
Hiep Le b88c64307f fix: show pointer cursor on NewConversationButton popover items (#297) 2026-05-11 13:57:38 +07:00
Hiep Le 93e6ef9114 fix: hide No conversations found when sidebar is collapsed (#295) 2026-05-11 13:48:57 +07:00
Hiep Le d968acdc3a fix: keep outer container padding consistent across routes (#293) 2026-05-11 13:34:19 +07:00
Robert Brennanandopenhands c8c08acd67 chore(dev:docker): bump default agent-server tag to 0924962-python (#289)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 20:17:21 -07:00
Robert Brennanandopenhands 0068b5f465 chore: address post-merge feedback on #284 + small cleanups (#288)
Three of the four post-merge review comments on #284 are still real:

- `src/utils/file-priority.ts` — `pathDepth` used `split('/').length - 1`
  without filtering empty segments, so a leading slash, trailing slash
  or double slash silently inflated the computed depth and dropped a
  top-level file behind genuinely-nested ones in the Files tab pill row.
  Aligned with the `.filter(Boolean)` convention already used by
  `buildFileTree`, with regression tests for both the leading-slash and
  double-slash cases.

- `src/utils/conversation-local-storage.ts` — `filesTabContentViewMode`
  is typed `ViewMode` ("rich" | "plain") but came from
  `JSON.parse(localStorage)`, so a corrupt / hand-edited value would
  leak past TypeScript into the UI. `sanitizeStoredState` now drops
  unknown values so the typed default re-applies, with a test that
  asserts `{ filesTabContentViewMode: 'fancy' }` falls back to 'rich'.

- `src/routes/files-tab.tsx` — the toolbar refresh button used
  `aria-label={t(I18nKey.COMMON$FILES)}` which translates to just
  "Files". Screen-reader users would hear the button as
  "Files button" instead of "Refresh files". Added a dedicated
  `FILES$REFRESH` translation ("Refresh files" + the 14 sibling
  locales), wired the button's aria-label and title to it, and updated
  the existing test to assert the new label.

The fourth comment (id-less events re-processed on every effect run in
`use-auto-refresh-files-on-edit`) was already addressed in a follow-up
commit via a `WeakSet<OHEvent>` keyed by object identity, so no change
needed there.

Also: `src/components/shared/buttons/refresh-button.tsx` and its
companion `src/icons/refresh.svg` had zero references in the codebase
(the only refresh button left in use is the inline one in
`routes/files-tab.tsx` with `u-refresh.svg`). Deleted.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 19:57:28 -07:00
Robert Brennanandopenhands b2fa082e6e chore: bump default agent-server image tag to 1916bb4-python (#287)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 19:41:02 -07:00
Robert Brennanandopenhands f2d787776a feat(onboarding): update default model and starter prompt (#286)
- Set default LLM to anthropic/claude-opus-4-7 in the onboarding LLM step
- Change the starter prompt to 'Create a basic webpage explaining what OpenHands can do'

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 19:35:23 -07:00
7ea79e0a39 feat(files-tab): Files tab with diff + rich/plain file viewer, safe-HTML markdown (#284)
* Add Files tab with file viewer and diff view modes

Replaces the previous Changes and App (served-host) tabs with a unified
Files tab that supports two top-level modes:

- Diff View (default when working inside a git repo): renders the
  existing Changes UI in-place.
- File Viewer: shows a quick-access row of the most important files
  (index.html, package.json, README.md, etc.) with an overflow dropdown,
  plus an on-demand expandable file tree. Selecting a file shows its
  content with a Rich/Plain toggle. Rich mode renders HTML, markdown,
  and images in a sandboxed iframe; Plain mode shows plaintext with a
  binary-fallback message.

Includes a new useIsGitRepo hook and supporting workspace-files hooks
plus utilities for sorting files by priority and building a tree from
flat paths.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(files-tab): only default to diff view when a repo was explicitly attached

The previous detection relied on whether 'git status' succeeded against the
workspace, but the agent-server initialises every workspace as an internal
git worktree for change tracking. As a result, a brand-new conversation with
no user-attached repo was incorrectly treated as a git repo and the Files
tab opened in diff view. Drop the filesystem probe and use the conversation's
'selected_repository' as the sole signal — that's the field populated by the
repo picker for 'an existing git repo' from the user's point of view.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): refresh button covers file list, auto-refresh on edits

Two related fixes to the Files tab data lifecycle:

1. The toolbar refresh button used to only refetch git changes (the diff
   view). It now also invalidates the workspace file list and any cached
   file contents, so clicking it works as expected in both modes.

2. Add a useAutoRefreshFilesOnEdit hook mounted by FilesTab that watches
   the conversation event store and invalidates the workspace-files,
   workspace-file-content and file_changes queries whenever the agent
   produces a mutating file-editor observation (create / str_replace /
   insert / undo_edit). Read-only 'view' commands and non-file
   observations are ignored. The hook is array-position based so it
   processes each event exactly once.

Tests: 4 new for the hook, all existing files-tab and conversation-tabs
tests still pass.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): tree toggle, full paths, real markdown rendering

UI changes to the workspace file viewer:

- Replace the trailing 'more files' overflow dropdown with a single
  caret button on the LEFT of the quick-access row that toggles the
  left-hand file tree. Tree is shown by default; users who want more
  horizontal space for the content pane can collapse it. There is no
  longer a dropdown listing extra files — anything that doesn't fit in
  the pills row is reachable by opening the tree.

- Pills in the quick-access row now display the full relative file
  path (e.g. 'src/main.ts') instead of just the basename, so users can
  distinguish between same-named files in different folders at a
  glance. The full path also serves as the tooltip.

- Markdown files are now rendered via the existing MarkdownRenderer
  (react-markdown + remark-gfm + remark-breaks) inside a styled
  prose container. The old approach piped raw text into a sandboxed
  <iframe> wrapped in <pre>, which displayed unrendered markdown
  source. The iframe path is removed for .md / .markdown / .mdx files.

Tests: 3 new cases in files-tab.test.tsx (full-path pills, tree-toggle
round-trip, markdown rendering with h1 + bold + no iframe). All 28
existing files-tab / conversation-tabs / auto-refresh tests still pass,
plus markdown component tests (27/27).

AGENTS.md now records the worktree policy: don't auto-switch the main
workspace away from the worktree's branch unless the user explicitly
asks.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): static fileserver + tab/toolbar layout pass

Switches the Files tab to the agent server's static workspace fileserver
(software-agent-sdk PR #3192) and shuffles the layout in response to
real-world usage feedback.

== Static workspace fileserver ==

The agent server now exposes each conversation's workspace at
GET /api/conversations/{conversation_id}/workspace/{file_path:path}.
A new utility, buildWorkspaceFileUrl, composes that URL from the
conversation_url (host + optional path-prefix for proxy deployments)
and conversation id.

useWorkspaceFileContent is refactored to:
  * always expose a 'staticUrl' field, so consumers can point an iframe
    or <img> at the same-origin fileserver and benefit from relative
    asset resolution.
  * skip fetching the bytes for image/PDF kinds entirely (the consumer
    renders staticUrl directly).
  * for text-classified files, fetch via the static URL with
    X-Session-API-Key auth instead of going through the typescript-
    client RemoteWorkspace.downloadAsBlob (which we no longer need for
    this view).
  * drop blobUrl and absolutePath from WorkspaceFileContent — they were
    only used by the iframe renderer, which now uses staticUrl.

FileContentViewer in rich mode:
  * HTML/SVG and PDFs render with <iframe src={staticUrl}> (no sandbox)
    so relative asset references load against the same origin.
  * Images render with <img src={staticUrl}> (no blob URL plumbing).
  * Markdown still uses MarkdownRenderer; plain mode unchanged.

Caveat (intentional, matches user spec): on agent servers with a
configured session_api_keys list, iframe src cannot send the
X-Session-API-Key header, so rich HTML/PDF previews won't load there.
Unauthenticated (auto_error=False, empty key list) servers — the
default for local dev — work.

== Tab bar / toolbar layout ==

  * Files tab moved to the leftmost slot in ConversationTabs (was
    second, after Planner). Task-list insertion adjusted from unshift()
    to splice(1, 0, ...) so Files stays leftmost even when present.
  * Refresh button removed from the top tab bar and re-homed inside the
    FilesTab toolbar; it now sits next to the diff/files and rich/plain
    toggles on the right edge of that row.
  * Rich/Plain toggle moved from the right-hand side of the toolbar to
    sit immediately next to the Diff/Files toggle on the left
    (justify-between → flex-start gap-3, with the refresh button using
    ml-auto).
  * Left-hand file tree collapsed by default (was expanded). The
    quick-row caret on the pill row is the toggle, as before.
  * 'Diff view' label shortened to just 'Diff' across all 15 locales in
    translation.json (key FILES retained to avoid a noisy
    rename in the generated declaration.ts).

== Tests ==

  * New: __tests__/utils/workspace-file-url.test.ts (7 tests) covers
    null guards, encoding of path segments, leading-slash stripping,
    omitted relativePath, and proxy-deployment path prefixes.
  * New: __tests__/i18n/files-diff-label.test.ts locks the renamed
    English label in translation.json (the test environment's i18next
    mock returns keys, so we assert against the source-of-truth file).
  * files-tab.test.tsx:
    - mock content shape updated to use staticUrl (no more blobUrl /
      absolutePath).
    - default-state expectations updated for the collapsed tree.
    - tree-toggle test inverted: hidden → expand → hide.
    - new test asserts HTML files render as <iframe src={staticUrl}>
      (no sandbox attribute).
    - new test asserts the refresh button lives in the files-tab
      toolbar and triggers refetchGitChanges.
  * conversation-tabs.test.tsx:
    - old 'refresh button in the top tab bar' test replaced with the
      inverse assertion (no standalone refresh <button> there now).
    - two new tests pin Files as the leftmost tab in both has-tasklist
      and no-tasklist cases.

All 40 focused tests pass; typecheck clean; src/ lint + prettier clean.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): GFM+safe HTML markdown; suppress diff default on empty repos

== Markdown rendering ==

MarkdownRenderer was only running remark-gfm + remark-breaks. Raw inline
HTML in markdown source (e.g. <details>, <kbd>, <mark>, GitHub-style
badges, or anchor target=_blank tricks) was silently dropped by
react-markdown's default behaviour. This commit:

  * Adds rehype-raw to parse raw HTML embedded in markdown into the
    rehype tree.
  * Adds rehype-sanitize to strip anything dangerous before render --
    scripts, event handlers, javascript:/data: URLs in href, and any
    tags not in the allow-list.
  * Extends the default sanitize schema with markdown-friendly extras
    (className/id/style on all elements; target+rel on anchors; img
    with safe src schemes only; <details>/<summary>, <figure>/
    <figcaption>, <mark>, <kbd>, <sub>, <sup>).
  * Restricts URL protocols on href to http/https/mailto/tel and on
    src to http/https/data (so data:image/... still works for inline
    base64 images, but data:text/html -- XSS vector -- does not).

The new behaviour is opt-out via a new MarkdownRenderer prop,
'allowHtml', which defaults to true. The sanitize schema makes raw HTML
safe by construction, so on-by-default is the right call -- and it's
consistent with how GitHub and most markdown renderers behave.

11 new markdown-renderer tests cover GFM tables/strikethrough/task
lists, inline HTML rendering (<mark>, <kbd>, <details>/<summary>),
sanitisation of <script>/onclick/javascript:/<iframe>, safe URL
schemes (https/mailto) passing through, and the allowHtml=false
opt-out path.

All 217 tests in the chat / diff-viewer / planner / conversation-panel
suites still pass -- confirming no regression from making allowHtml
default-on for the existing call sites.

== Diff-view default for empty repos ==

The Files tab was defaulting to diff view whenever the conversation
had a selected_repository, even on attached repos with zero commits
(unborn HEAD -- e.g. a freshly-created empty GitHub repo). In that
state the diff view has nothing to diff against and looks broken; the
file viewer is a much better landing experience.

  * Adds src/hooks/query/use-has-git-commits.ts -- a thin useQuery-
    backed hook that shells out via the conversation's RemoteWorkspace
    to run 'git rev-parse --verify HEAD' in the working dir. Exit 0 ->
    hasCommits: true; non-zero -> false. The enabled flag is plumbed
    through so we don't probe when there's no attached repo to check
    (saves a workspace round trip on every plain conversation).

  * FilesTab now derives diffViewDefault as
      isGitRepo && hasCommits !== false
    -- i.e. only enables diff by default when both conditions hold,
    treating the in-flight 'null' state as optimistically true so we
    don't get a files->diff flash on the common path.

3 new files-tab tests cover empty-repo behaviour, the enabled-gating
of the probe when no repo is attached, and the optimistic in-flight
default.

== Tests / Quality gates ==

  * typecheck: clean (react-router typegen + tsc).
  * eslint + prettier on changed src files: clean.
  * Vitest focused run: 52/52 (markdown + files-tab).
  * Vitest chat + diff-viewer + planner + conversation-panel: 217/217
    (no regressions from default-on allowHtml).

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): mint workspace cookie via startWorkspaceSession for iframe/img auth

Bumps @openhands/typescript-client to feat/workspace-static-session
(f062287d) which adds POST /api/auth/workspace-session. Calling it
exchanges the X-Session-API-Key for an HttpOnly cookie scoped to
/api/conversations -- which is the only auth mechanism the browser
attaches to top-level <iframe src> / <img src> requests.

== New plumbing ==

  * src/api/typescript-client.ts: factory createRemoteConversation(id)
    that wraps RemoteConversation with a placeholder Agent (required by
    the constructor but unused for startWorkspaceSession). RemoteConv
    and Agent come from the package root -- they're not exposed under
    the package's subpath exports.

  * src/hooks/query/use-workspace-session.ts: useWorkspaceSession hook
    that fires startWorkspaceSession once per conversation via react-
    query, caches the result with staleTime: Infinity (the cookie
    sticks in the browser jar; re-issuing the POST is wasted work),
    and exposes { baseUrl }. retry: false because a 401 here is a
    fixed config issue, not transient.

  * Same file: joinWorkspaceUrl(baseUrl, relativePath) -- URL-encodes
    each path segment but preserves "/" separators. Replaces the
    standalone src/utils/workspace-file-url.ts which is removed.

== Refactor ==

  * use-workspace-file-content.ts now derives staticUrl from
    useWorkspaceSession's baseUrl via joinWorkspaceUrl, gates the
    query on !!baseUrl (so we don't fire fetches against an
    unauthenticated URL), and switches fetch() to credentials:
    "include" instead of the X-Session-API-Key header. This makes
    our JS fetch ride the same auth path as the iframe/img -- one
    behavior, one CORS story, no preflight for a custom header.

== Tests ==

  * __tests__/hooks/query/use-workspace-session.test.tsx -- 9 tests:
    happy path (POST fires, baseUrl flows through, createRemote
    Conversation gets the right args); runtime-not-ready and no-
    conversation-id both gate the POST; error surfaces as isError +
    error.message; joinWorkspaceUrl covers empty / single-segment /
    nested / leading-slash / unicode + space encoding.

  * Deleted __tests__/utils/workspace-file-url.test.ts -- the helper
    it pinned is gone, joinWorkspaceUrl is covered by the new tests.

== Quality gates ==

  * typecheck clean (react-router typegen + tsc).
  * Targeted vitest run: 251/251 across markdown / files-tab / chat /
    diff-viewer / planner / conversation-panel / workspace-session.
  * Lint has known issues in the new test file (display-name and
    function-component-definition warnings on the QueryClient wrapper
    factory; no-promise-executor-return on the `await new Promise(r =>
    setTimeout(r, 10))` polling -- both patterns already in use in
    other __tests__/hooks/query/*.test.tsx files); will sweep
    separately.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore(files-tab): clear remaining lint errors on the branch

* __tests__/hooks/query/use-workspace-session.test.tsx: name the
  QueryClientProvider wrapper component (silences react/display-name
  + react/function-component-definition) and lift the
  setTimeout("yield to scheduler") trick into a flushScheduler()
  helper (silences no-promise-executor-return -- ESLint forbids
  returning a value from a Promise executor).

* src/utils/conversation-local-storage.ts: replace the destructure-
  with-throwaway-name pattern (which tripped naming-convention on
  `_drop`) with a plain spread + delete -- one statement clearer,
  no rename gymnastics. Also re-wrap the signature so prettier is
  happy.

* src/components/features/conversation/conversation-tabs/conversation-
  tab-content/conversation-tab-content.tsx: prettier reformat (one
  long line broken).

Verified: full `npm run lint` passes (10 pre-existing warnings in
files outside this branch's scope remain).

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): use RemoteWorkspace.startWorkspaceSession directly

typescript-client PR #155 was reshaped to land startWorkspaceSession
on RemoteWorkspace (taking conversationId as an argument) rather than
on RemoteConversation. The new shape is strictly simpler for our use
case: minting a workspace cookie no longer requires constructing a
placeholder Agent + RemoteConversation just to call one method.

Changes:

* package.json / package-lock.json: bump @openhands/typescript-client
  pin to github:OpenHands/typescript-client#6b5a65c5 (head of
  feat/workspace-static-session, sole commit on PR #155).

* src/api/typescript-client.ts: drop the createRemoteConversation
  factory along with the Agent + RemoteConversation imports it
  needed -- callers can now go through createRemoteWorkspace (which
  already existed for git-service) for everything we use.

* src/hooks/query/use-workspace-session.ts: call
  createRemoteWorkspace({ conversationUrl, sessionApiKey }) and then
  workspace.startWorkspaceSession(conversationId). Same return value
  (baseUrl string), same caching semantics.

* __tests__/hooks/query/use-workspace-session.test.tsx: rename the
  mocked factory + assertions to match. The hook's surface (data,
  isLoading, isError, error) is unchanged so the rest of the suite
  is untouched.

Verified: typecheck clean, workspace-session + files-tab tests
(23/23) pass, full `npm run lint` reports 0 errors (10 pre-existing
warnings in unrelated files remain).

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(files-tab): persist toggles + cache-bust iframe + depth-first sort + external-open link

Five behavioural improvements to the Files tab, plus the corresponding
typescript-client pin bump now that PR #155 has merged to main.

1. Persist diff-view + rich/plain choice per-conversation. Both Files
   tab toggles now live in conversation localStorage rather than
   transient component state, so switching to another conversation and
   back restores whatever view the user last selected. Implemented by
   adding `filesTabDiffView` (nullable -- null means 'fall back to
   repo-aware default') and `filesTabContentViewMode` to
   `ConversationState`, exposing matching setters from
   `useConversationLocalStorageState`, and wiring the toolbar
   `SegmentedToggle`s to them. When the hook is called with an empty
   or task-placeholder id it now mirrors updates into local React
   state instead of dropping them on the floor -- keeps the UI
   reactive in unit tests / pre-route renders.

2. Fix initial files→diff flash inside a real repo. While
   `useIsGitRepo` is still loading we now stay optimistic
   (`isGitRepo || isGitRepoLoading`), matching the existing
   optimism around `hasCommits`. The user-visible bug was: a brand
   new conversation attached to a git repo would show files-view for
   one frame before flipping to diff-view, and any persisted choice
   we made during that frame stuck.

3. Cache-bust iframes / images after every file-editor observation.
   New `useWorkspaceMutationCounter` zustand store: a monotonic
   counter `useAutoRefreshFilesOnEdit` bumps every time it sees a
   mutating `FileEditorObservation` /
   `StrReplaceEditorObservation` /
   `PlanningFileEditorObservation`. `FileContentViewer` and the
   toolbar 'open in new window' link append it to the workspace
   static URL as `?v=<n>`, so the browser refetches HTML/CSS/images
   the agent just rewrote on disk instead of showing the stale
   cached response. Read-only `view` observations and unrelated
   kinds (e.g. `ExecuteBashObservation`) don't bump.

4. Depth-first file ranking. `sortFilesByPriority` now sorts by
   path depth first (shallower wins unconditionally), then by
   high/secondary basename importance, then alphabetically.
   Concretely: top-level `README.md` outranks
   `foo/bar/index.html`, but `index.html` still beats
   `README.md` at the same depth. Updated docs + added tests for
   the new contract.

5. 'Open in new window' affordance. New external-link button in the
   Files tab toolbar, rendered next to the refresh button while in
   file-viewer mode whenever a file is selected and we've resolved
   its workspace static URL. Hidden in diff-view (no single
   meaningful URL to point at). Cache-bust query string applied so
   the popped-out tab also sees the latest bytes.

Also bumps `@openhands/typescript-client` to
`ef62e82fc3dfb03991a1c8025429caf354427263` -- the merge commit of
PR #155 on main. No API change vs the previous
`6b5a65c5...` pin (that was the only commit on the merged
branch); this just gets us off the soon-to-be-deleted feature
branch ref.

Tests:
* Full suite: 1805 passed / 12 skipped / 9 todo across 283 files.
* `__tests__/utils/file-priority.test.ts`: added two new cases
  pinning the depth-first + same-depth rules.
* `__tests__/hooks/use-auto-refresh-files-on-edit.test.tsx`:
  added two cases pinning the mutation-counter bump (yes on
  mutations, no on `view` / non-file observations).
* `__tests__/routes/files-tab.test.tsx`: existing iframe-src test
  switched from strict equality to a `^staticUrl\?v=\d+$` regex
  to allow the cache-buster suffix.
* `__tests__/hooks/use-draft-persistence.test.tsx` and
  `__tests__/hooks/use-handle-plan-click.test.tsx`: fixtures
  updated to include the new ConversationState fields and matching
  setter mocks.

Lint clean (0 errors; 10 pre-existing warnings remain). Typecheck
clean. New i18n key `FILES$OPEN_IN_NEW_WINDOW` translated across
all 15 locales.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(files-tab): address PR #284 review comments

- Markdown sanitizer: drop `style` attribute from allowlist (CSS-injection /
  data-exfiltration channel) and remove `data:` from `src` protocol allowlist
  (data:text/html bypass).
- HTML / PDF preview iframes: add `sandbox="allow-same-origin"` so scripts
  and inline event handlers in previewed files cannot execute in the canvas
  context while relative asset refs still resolve.
- Auto-refresh hook: track processed event ids in a Set instead of slicing
  the tail by length, so out-of-order events inserted into the (sorted)
  event store still trigger invalidation + cache-bust.
- File tree builder: replace O(n) `children.find` with an O(1) side-table
  Map per parent; promote a leaf node to a directory if a deeper path
  arrives later.
- Conversation localStorage: filter removed tab names ("editor", "served",
  "changes", "app") out of `selectedTab` and `unpinnedTabs` on read so
  ghost entries don't linger.
- File-content viewer: distinguish load-error from binary-fallback with a
  new `FILES$LOAD_ERROR` i18n key.
- package.json: exact-pin `rehype-raw` and `rehype-sanitize` (no caret).
- Tests: add security regressions (style attr, data:text/html, inline
  event handlers), tree promotion + wide-dir smoke test, out-of-order
  mutation event test, unpinnedTabs migration test; update HTML-preview
  iframe sandbox assertion.

Co-authored-by: openhands <openhands@all-hands.dev>

* Update src/components/features/markdown/markdown-renderer.tsx

Co-authored-by: OpenHands Bot <contact@all-hands.dev>

* feat(files-tab): drop "read-only" from terminal title, theme markdown preview, prism-highlight source/plain views

Three small UI changes that go together:

1. Terminal tab title — strip the "(read-only)" qualifier from
   COMMON$TERMINAL in every locale. The fact that the embedded xterm
   doesn't echo stdin is internal plumbing; users just want to see
   "Terminal" in the tab strip.

2. Rich-mode markdown preview — paint the wrapper in the right-pane
   chrome color (\#25272D) and force every text node to white. The old
   `bg-white text-[#222]` made markdown files look like a stark card
   floating on the dark canvas. Switched to `prose prose-invert` and
   layered arbitrary CSS-variable utilities (`[--tw-prose-body:#fff]`
   et al.) on top, because the typography plugin's prose-invert default
   is off-white (#e5e5e5) rather than pure white. Existing custom
   heading components already use text-white and continue to win.

3. Source-code views (rich AND plain) and plain views of markdown/HTML
   — feed everything through the existing PrismLight pipeline used by
   chat code-block rendering. New `HighlightedSourceView` component
   wraps SyntaxHighlighter with vscDarkPlus + a transparent background
   so the highlighter blends with the right-pane chrome instead of
   painting its own slab. New `getPrismLanguageForFile` util resolves
   extensions (.ts, .py, .yaml, ...) and well-known no-extension
   filenames (Dockerfile, Makefile, .bashrc, ...) to Prism grammars,
   falling back to a raw `<pre>` when nothing matches.

Behavior matrix in the files tab now reads:

  Rich mode:
    HTML/SVG  -> sandboxed iframe preview
    Markdown  -> rendered (dark bg, white text)
    Image     -> <img>
    PDF       -> sandboxed iframe
    Source    -> highlighted source (no other "rich" form for code)

  Plain mode:
    Source    -> highlighted source
    Markdown  -> highlighted markdown source (see the markup)
    HTML      -> highlighted markup source
    Other     -> raw <pre> fallback (rare)
    Binary    -> binary fallback message

Tests:
- New unit tests for getPrismLanguageForFile (extensions, no-ext
  filenames, case-insensitive, mime-type fallback, null on unknown).
- files-tab integration tests now assert the markdown wrapper paints
  bg-[#25272D]/text-white, and that toggling .md to plain renders
  highlighted markdown source rather than rich markup.

Lint and full test suite intentionally not re-run on this commit;
follow-up commits address upstream issues and PR review feedback.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(markdown): repair botched suggestion in fc208bc — collapse rel-attribute schema

fc208bc applied PR review feedback via the GitHub web-UI "commit
suggestion" button, but the suggested replacement ended up *inside*
the existing array literal instead of replacing it:

    a: [
      ...(defaultSchema.attributes?.a ?? []),
      "target",
      a: ["href", "title", "target", "rel"],  // ← syntax error
    ],

That's a labeled-statement-like token inside an array literal — it
fails both `tsc` and `eslint` parsing, breaking the branch's build
and typecheck for everyone pulling this PR.

Fix-forward (preserves rbren's authorship of fc208bc in history) by
applying the reviewer's actual intent: collapse the `a` allow-list
to `["href", "title", "target", "rel"]`. This addresses the
security concern the reviewer raised in thread 3215928329 — the old
`["rel", "noopener", "noreferrer", "nofollow"]` form is
rehype-sanitize's exact-value variant, which strips the standard
space-separated `rel="noopener noreferrer"` and reintroduces a
reverse-tabnabbing vector on raw HTML anchors with `target="_blank"`.
Added a comment in the schema explaining the reasoning.

A regression test for this is added in the next commit (PR review
feedback round 2) alongside the rest of the round-2 fixes.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(files-tab): address PR #284 round-2 review comments

Round 2 of review feedback on PR #284. The five threads addressed
here all came in together at 01:39 UTC; the related rel-attribute
schema fix is in the previous commit (7c099bf).

Hook (use-auto-refresh-files-on-edit):

* Guard against undefined event.id (3215928337). The event store
  accepts events with no id (`getEventId` returns
  `string | number | undefined`). The previous version of the hook
  blindly called `has(event.id)` / `add(event.id)`, which put the
  literal `undefined` into the Set on the first id-less arrival and
  then silently swallowed every subsequent id-less event because the
  Set already contained that key. Now we only consult/touch the set
  when the id is defined; id-less events are always treated as new.
* Widen processedIdsRef from Set<string> to Set<string | number>
  (3215928342). The formal EventID type is string, but the event
  store itself uses Set<string | number> defensively and getEventId
  returns string | number | undefined — the hook mirrors that
  tolerance so a stray numeric id never sneaks past dedup.

Tests added for the above (3215928347):

* `does NOT deduplicate id-less events (every id-less arrival is a
  new event)` — three distinct id-less FileEditorObservations land
  in sequence and we expect three counter bumps; with the old
  implementation only the first would land.
* `dedupes numeric event ids the same way as string ids` — same
  numeric-id event added twice produces exactly one counter bump.

Markdown sanitizer (3215928336):

* Export MARKDOWN_SANITIZE_SCHEMA so tests can target the schema
  directly. Wrote a docstring explaining why the through-component
  test the reviewer suggested wouldn't catch the bug: our custom
  `anchor` component hard-codes `target="_blank" rel="noopener
  noreferrer"`, so the final DOM is safe regardless of what the
  schema does to HAST. We have to test the schema in isolation.
* Two new `describe("MARKDOWN_SANITIZE_SCHEMA")` tests that run
  hast-util-sanitize directly on hand-built HAST trees:
  - rel="noopener noreferrer" survives sanitization (regression
    for the fc208bc bug — the old exact-match schema would have
    stripped it)
  - rel="nofollow ugc" also survives (locks in the property that
    *any* rel-token combination is safe, since rel doesn't execute
    code or navigate)

Conversation local storage (3215928349, 3215928351):

* Existing unpinnedTabs filter test extended from
  `["editor", "changes", "served"]` to all four removed tabs
  (`+"app"`). The previous version of the test missed "app" and
  that gap is exactly what let the original whitelist-vs-denylist
  bug slip through.
* New `describe("filesTabDiffView persistence")` block with four
  tests: default-null, round-trip true, round-trip false, isolation
  between conversations. The boolean is per-conversation and its
  default-null is load-bearing — the higher layer relies on null to
  apply the git-repo-aware default.
* New `describe("filesTabContentViewMode persistence")` block
  with four tests: default-'rich', round-trip 'plain', round-trip
  explicit 'rich', isolation between conversations. The 'rich'
  default is locked in so a careless field-initializer rename
  doesn't quietly flip every user from rich to plain after deploy.

Lint and full test suite intentionally not re-run on this commit per
the user's instruction; will run on the next pass once any further
review feedback is in.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(files-tab): make CI green (lint + correct id-less event dedup)

CI was red on `4e9271b` for two reasons:

1. **Lint errors** (prettier + no-continue) that I'd skipped on the
   user's instruction during the round-2 commit:
   * `src/hooks/use-auto-refresh-files-on-edit.ts:75` — `no-continue`
     violation from the new id-guard. Rewrote to use an inverted
     `alreadyProcessed` predicate instead of `continue` so the
     control flow reads the same way without breaking the lint rule.
   * `src/utils/file-language.ts` — three prettier diffs
     (string-quote style + Math.max arg wrapping). Auto-fixed via
     `eslint --fix`.
   * `src/components/features/files-tab/file-content-viewer.tsx` —
     two prettier wraps on long className + JSX expression that
     showed up after the round-1 round of edits. Auto-fixed.

2. **A real bug** the lint-fix exposed in the round-2 test: the
   suggested test (and mine, which followed it) asserted
   `counter === 3` after adding three id-less events inside a single
   `act()` block. That assertion is wrong because the hook bumps the
   counter exactly once per effect flush (the bump is outside the
   event loop), and act() batches all three addEvent calls into one
   flush. But while investigating, I found the underlying bug the
   reviewer was probing toward: id-less events were not durably
   deduped. The events array is rebuilt on every store mutation but
   its element references are stable, so a single id-less event
   would re-trigger the bump on every subsequent render — spamming
   cache invalidations forever.

   Fix: added a second tracking store, `processedEventsRef`, a
   `WeakSet<OHEvent>` keyed by object reference. Id-less events
   dedup by reference; id-bearing events still dedup by id. The
   WeakSet doesn't pin the events in memory after the store clears
   them, so there's no leak. Long explanatory comment in the hook
   covers both halves of the contract (why we can't put `undefined`
   in the id Set, and why id-less events still need *some* form of
   dedup).

   Tests restructured accordingly:
   * `processes each id-less event distinctly` — three SEPARATE
     `act()` calls (one per event) so each gets its own effect
     flush; counter ends at 3. Comment explains why the
     single-act() version of this assertion is meaningless.
   * `does NOT re-bump on subsequent renders for the same id-less
     event` — NEW test that catches the spurious-rebump bug
     directly: add one id-less event, then `rerender()` three
     extra times, assert counter stays at 1. This would fail
     against the previous version of the fix (no WeakSet path).

Verified locally: `npm run typecheck` clean, `npm run lint` 0 errors
(10 pre-existing warnings in files I didn't touch), `npm test` all
1862 tests pass (286 files, 12 skipped + 9 todo — all pre-existing).

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-05-10 19:29:16 -07:00
Robert Brennan 2d8f260c09 Update README with multi-VM backend support
Added information about running the backend on multiple VMs.
2026-05-10 18:28:32 -07:00
Robert Brennan 068ee47300 Update README.md 2026-05-10 17:10:16 -07:00
Robert Brennanandopenhands 3e57aa6a04 feat(chat): queue pending user messages with sending/error/retry states (#281)
* feat(chat): queue pending user messages with sending/error/retry states

Replace the single optimisticUserMessage slot with a FIFO queue of pending
user messages so the user gets immediate feedback when they hit send and
multiple in-flight messages do not clobber each other.

- Reshape optimistic-user-message-store around enqueuePendingMessage /
  consumeOldestSendingMessage / markPendingMessageError /
  markPendingMessageSending with a 'sending' | 'error' status per entry.
- Render queued messages via a new PendingUserMessages component using
  ChatMessage's new pendingStatus + onRetry props, applying a faded
  treatment for 'sending' and an error banner + retry link for 'error'.
- Wire the WebSocket context to consume the oldest 'sending' entry when
  the server echoes a UserMessageEvent back, so the queue drains FIFO.
- Update ChatInterface, GitControlBar, TaskCard and useHandleBuildPlanClick
  to enqueue pending messages, and flip the matching entry to 'error'
  when the send call rejects.
- Add i18n strings for Sending / Send failed / Retry.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): scope pending message queue per conversation and stop double 'Sending' render

Two follow-up fixes on top of the pending-message-queue refactor:

1. **Per-conversation scoping.** The optimistic queue is global but each entry
   is now tagged with the `conversationId` it was enqueued from, and
   `PendingUserMessages` filters to entries matching the active conversation
   via `useOptionalConversationId`. This means switching conversations no
   longer carries 'Sending…' bubbles over, and the WebSocket
   `UserMessageEvent` ack for one conversation can never pop a pending entry
   belonging to another. `consumeOldestSendingMessage` now takes the
   conversation id and only matches within it.

   Call sites updated: `chat-interface.tsx`, `git-control-bar.tsx`,
   `use-handle-build-plan-click.ts`. `task-card.tsx` moves the enqueue into
   the `createConversation` `onSuccess` callback so the message is tagged
   with the newly-created conversation id.

2. **Duplicate 'Sending…' bubble.** `<PendingUserMessages />` was rendered
   from two places after the previous rebase: once at the bottom of
   `<Messages>` (where the pending queue would never re-render anyway
   because `Messages` is `React.memo`'d on event ids) and once
   unconditionally from `<ChatInterface>`. Removed the render inside
   `<Messages>` so only the ChatInterface render remains.

   Also fixed a separate double-submit path in `custom-chat-input.tsx`: the
   `submittedMessage` effect listed `onSubmit` in its deps, but
   `onSubmit` (= `handleSendMessage`) is a fresh function on every parent
   render, and the parent re-renders synchronously when the new
   `enqueuePendingMessage` call mutates the store. That made the effect
   fire twice for the same `submittedMessage` value before
   `setSubmittedMessage(null)` flushed. Pinned `onSubmit` behind a ref and
   dropped it from the dep array.

Tests updated to pass `conversationId` and a new test asserts the queue is
not consumed across conversations.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): address review feedback on pending-message queue

- plan-preview.test.tsx: include `useOptionalConversationId` in the
  `#/hooks/use-conversation-id` mock so the test passes now that
  `useHandleBuildPlanClick` depends on it. This was failing all 21
  PlanPreview tests in CI on the previous push.

- optimistic-user-message-store: replace the module-level counter
  (which never reset between tests) with a `Date.now()` +
  base36 random suffix. Same uniqueness guarantee, no shared state.

- optimistic-user-message-store: collapse `consumeOldestSendingMessage`
  into a single atomic `set()` so the find and the filter can't
  observe an interleaved update from another action.

- git-control-bar: capture the `pendingId` from the
  `enqueuePendingMessage` return value and, if the `send` promise
  rejects, mark that entry as error so the user gets a retry link
  instead of a stuck 'Sending…' bubble.

- chat-message: add `role="status" aria-live="polite"` to the
  'Sending…' label and `role="alert"` to the error label so screen
  readers announce send-state transitions.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(chat): content-keyed echo matching + 60s watchdog timeout for pending messages

Addresses the bot's reposted 'critical' review threads on PR #281.

**Out-of-order echo matching.**
- `PendingUserMessage` now stores both `text` (user-visible bubble) and
  `content` (the exact string sent to the server, which may include the
  appended 'Files uploaded: …' prompt). `enqueuePendingMessage` accepts
  an optional `content` and defaults it to `text` for call sites that
  don't transform the prompt.
- New `consumeMatchingPendingMessage(conversationId, content)`. It does
  an exact content match first — so an echo of 'second' arriving before
  an echo of 'hello' correctly pops 'second' instead of the oldest
  entry — and falls back to the oldest "sending" entry in the same
  conversation if no exact match exists (lets us still drain the queue
  if the server slightly munges the body).
- `conversation-websocket-context` extracts the echoed text by joining
  the `TextContent` parts of `event.llm_message.content` and passes it
  to the new matcher. Both consumption sites (main WS + planning agent
  WS) use the matcher with the main `conversationId`, so a planning
  sub-agent echo can never consume a main-conversation pending entry.
- `chat-interface` passes `content: prompt` (text + file annotations)
  to `enqueuePendingMessage`.

**60-second watchdog timeout.**
- `enqueuePendingMessage` now schedules a `setTimeout` for
  `PENDING_MESSAGE_TIMEOUT_MS` (60s, exported). If the entry is still in
  'sending' state when the timer fires, it's flipped to 'error' with
  message 'Send timed out' so the user gets a retry link instead of a
  permanently-stuck bubble. Timeout is a no-op if the echo already
  consumed the message or if it was already marked error explicitly
  (the original `errorMessage` is preserved).
- 60s is long enough to cover legitimately slow uploads / agent-server
  latency but short enough to actually rescue stuck bubbles.

**Tests.**
- `optimistic-user-message-store.test.ts` adds coverage for: storing
  separate `text`/`content`, exact-match preference, FIFO fallback,
  skipping entries already in 'error', cross-conversation isolation
  with identical content, watchdog timeout firing, and the two no-op
  cases (echo already consumed, message already failed).

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 16:49:48 -07:00
Robert Brennanandopenhands a7b35f7098 fix(changes): show placeholder instead of error toast when expanding a deleted file (#285)
The agent-server's /api/git/diff endpoint calls path.exists() before
anything else and returns HTTP 400 (GitPathError) for files that no
longer exist on disk. Expanding a deleted file in the Changes view
therefore triggered the global QueryCache onError handler and surfaced
an error toast.

Disable the unified-git-diff query when the change type is 'D' and
render a localized 'file deleted' placeholder in FileDiffViewer (no
view-mode toolbar, no Monaco editor) so the expansion is a no-op
visually instead of an error.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 16:09:55 -07:00
Robert Brennanandopenhands 0eb0ca5dfa fix(chat): render InvokeSkill action/observation with skill name (#283)
Previously, an agent's invoke_skill tool call rendered as the literal
'INVOKESKILL' in the chat — the default fallback in getActionEventTitle
was uppercasing the action kind because InvokeSkillAction wasn't a
recognized kind.

Add InvokeSkillAction / InvokeSkillObservation to the core type unions
and route them through the same translation-key machinery as the other
built-in tools, so the title now reads 'Invoking skill <name>' / 'Invoked
skill <name>' and the expanded card shows the rendered skill content
instead of a JSON blob.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 15:08:29 -07:00
Robert Brennanandopenhands 400be5a3d4 feat(chat): add "upload as file" checkbox for attached images (#282)
When the user attaches an image to the message input, a checkbox
labeled "upload as file" now appears alongside the previews. When
checked, attached images are routed through the normal file-upload
path (the same path that handles non-image attachments) instead of
being converted to base64 and embedded in the message sent to the
LLM.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 15:08:12 -07:00
Robert Brennanandopenhands 31302f9282 fix(ingress): handle socket errors so ECONNRESET can't crash the proxy (#280)
A WebSocket flowing through scripts/ingress.mjs would take down the
whole ingress process whenever its underlying TCP socket reset:

  Error: read ECONNRESET
      at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
  Emitted 'error' event on Socket instance at:
      at Socket.onerror (node:internal/streams/readable:1026:14)

proxyWebSocket() only attached an 'error' listener to the outbound
HTTP upgrade request, never to the raw client / upstream sockets that
the bidirectional pipe runs over. ECONNRESET on a long-lived
/sockets/events/... connection (browser tab close, NAT timeout,
mobile network handoff, …) therefore became an unhandled 'error' event
on a Socket and Node aborted the process.

Fix:
  - Attach 'error' (and 'close') listeners to both the client socket
    and the upstream socket in proxyWebSocket; on either side erroring,
    tear the peer down gracefully.
  - Mirror the same defensive handling for plain HTTP in proxyRequest:
    add 'error' handlers on req, res, and proxyRes so a mid-stream
    disconnect aborts the upstream call instead of crashing.
  - Add server.on('clientError') for malformed client requests.
  - Add a narrow uncaughtException guard that swallows benign socket
    teardown errors (ECONNRESET / EPIPE / ECONNABORTED /
    ERR_STREAM_PREMATURE_CLOSE) but rethrows everything else, so real
    bugs stay visible.

Tests:
  - New regression covering an upstream WebSocket that immediately RSTs
    after upgrading; before the fix this took the proxy down (next
    request fails with ECONNREFUSED), after the fix the process keeps
    serving HTTP traffic and stderr never shows "Unhandled 'error' event".
  - New regression covering a client that aborts an in-flight HTTP
    request mid-flight.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 14:08:21 -07:00
Robert Brennanandopenhands d9beb15250 feat(sidebar): collapsible left-hand nav with icons + hover tooltips (#279)
* feat(sidebar): make left-hand nav collapsible with icons + hover tooltips

- Add collapse/expand toggle (block-drawer-left icon) to the sidebar
- Top-level nav items (Conversations, Automations, Skills, Settings) now
  render with leading icons in expanded mode and icon-only in collapsed
  mode
- Persist collapse state to localStorage via useSidebarCollapsedState
- New SidebarCollapseContext lets descendant lists adapt their layout
- Conversation list switches to a compact rail (status-dot rows) when
  collapsed, with a HeroUI Tooltip showing the full conversation card on
  hover; New Conversation button becomes a square + button
- Hide the inline 'older conversations / start tasks' summary in
  collapsed mode (the popover preview already exposes that detail)
- Switch message.svg to currentColor so it tracks active/muted nav state
- Tests: cover toggle behavior, persistence, and that every top-level
  nav item ships with an icon

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor(sidebar): caret toggle + suppress nested status-dot tooltip

- Replace the block-drawer-left collapse glyph with a plain
  ChevronLeft / ChevronRight from lucide-react. The previous icon read
  as a small white 'pill' wedged next to the main panel shape; the
  caret is much clearer as a directional toggle.
- Add an optional showTooltip prop to ConversationStatusDot (default
  true to preserve existing call sites). The compact conversation row
  in the collapsed sidebar now opts out, so hovering the status dot no
  longer races a tiny status tooltip against the full conversation
  preview tooltip.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(sidebar): hoist lucide-react import to satisfy import/order

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor(sidebar): reposition caret, drop white tooltip, reorder nav, polish compact list

Sidebar header:
- Drop the StyledTooltip on the collapse toggle (the white pill on hover).
  The chevron direction already conveys what the button does; aria-label
  is kept for screen readers.
- Expanded: chevron is pushed flush to the right edge of the rail via
  'ml-auto md:-mr-2' (header gets pl-2/pr-0 so the negative right margin
  escapes most of the aside's pr-3).
- Collapsed: chevron now sits in the same row as the logo (was stacked
  below). Collapsed rail widens from 64px → 80px with px-1.5 to fit a
  46px logo + small w-5 chevron side-by-side.

Nav:
- Reorder Skills above Automations.

Compact conversation list:
- New-conversation '+' button was visually off-center because StyledTooltip
  wraps its children in inline-flex (sized to the trigger), which neutered
  the trigger's own mx-auto. Wrap the relative positioner in
  'flex justify-center' when compact so the trigger is centered against
  the rail itself.
- Hide 'older' conversations and the 'Load more' control in compact mode
  so the icon rail mirrors the expanded sidebar's default — recent
  (within the past hour) only, no archive cruft.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(sidebar): stack chevron below logo when collapsed

Walks back the previous attempt to fit logo + chevron side-by-side in
the collapsed rail (which required widening it to 80px). Going back to
the original 64px rail with the chevron centered below the logo —
cleaner visual, and consistent with the rail's icon-stack rhythm
(logo, chevron, then the nav-link column).

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 14:07:26 -07:00
Robert Brennanandopenhands 491a3f6cdf Lazily load conversation history: 50 most recent first, paginate on s… (#252)
* Lazily load conversation history: 50 most recent first, paginate on scroll

Previously every conversation page loaded its entire event history in a single
REST call (`limit: 100` plus a WebSocket `resend_all` replay), which is slow
and wasteful for long-running conversations.

Now:
- `EventService.searchEvents` takes an options bag (`limit`, `pageId`,
  `sortOrder`, `timestampGte`, `timestampLt`) and returns the raw `EventPage`
  so callers can paginate. Both local and cloud-proxy paths forward the
  params.
- `useConversationHistory` fetches only the most recent
  `INITIAL_HISTORY_PAGE_SIZE` (50) events with `sort_order='TIMESTAMP_DESC'`
  and reverses them to chronological order.
- `useLoadOlderEvents` is a new on-demand hook that paginates older events
  via `timestamp__lt = <oldest known timestamp>`. `ChatInterface` triggers it
  when the user scrolls within 80px of the top, shows a loading spinner, and
  preserves the visible scroll position by adjusting `scrollTop` by the
  inserted height delta.
- `ConversationWebSocketProvider` waits for the REST query to settle before
  opening the main socket, then subscribes with `resend_mode='since'` and
  `after_timestamp = <latest preloaded event timestamp>`, falling back to
  `resend_mode='all'` if REST returned no events or errored. The legacy
  count-based history loading detection is dropped for the main connection.
- Event store gained a bulk `addEvents` action (used for the initial REST
  seed and for "scroll-up" pagination) that re-sorts by timestamp once at
  the end so prepending an older page stays O(N log N) overall.

Co-authored-by: openhands <openhands@all-hands.dev>

* Render Messages when any renderable events exist

The previous gate (`conversationUserEventsExist`) hid the chat whenever the
loaded window contained no `source: 'user'` events. With the new lazy
"50 most recent" REST fetch, long agent runs between user turns can push
the original prompt out of the initial window, leaving Messages unmounted.
Without rendered messages there is no scroll container content, so
'scroll up to load older' never fires — the user appears stuck on a blank
chat.

Switch the gate to `renderableEvents.length > 0`. The empty-state
ChatSuggestions block above keeps its own `!userEventsExist &&
!hasSubstantiveAgentActions` gate, so brand-new conversations still show
suggestions instead of an empty chat.

Adds a regression test for the bug and a sibling test that the scroll
handler issues `searchEvents({ timestamp__lt })` when the user scrolls
near the top.

Co-authored-by: openhands <openhands@all-hands.dev>

* Handle paginated history edge cases

Co-authored-by: openhands <openhands@all-hands.dev>

* Trigger loadOlder when pinned at top or list doesn't overflow

The browser only dispatches a 'scroll' event while `scrollTop` is
actually changing. Two paginated-history cases never triggered:

  1. The user is already at `scrollTop = 0` and tries to wheel further
     up (overscroll). No scroll event fires.
  2. The initial 50-event window is short enough to fit on screen, so
     there is no scrollbar at all and nothing to scroll.

In both cases the user had to scroll down a bit and back up to coax
loadOlder into firing.

Replace `handleScrollForPagination` with a single `maybeLoadOlder`
predicate that fires when `scrollTop <= threshold` OR
`scrollHeight <= clientHeight + threshold`, and wire it from three
sources:

  - onScroll  (existing path: user scrolls near the top)
  - onWheel   (catches overscroll-at-top: scrollTop == 0 and deltaY < 0)
  - useEffect (catches no-overflow: re-runs after each rendered page so
              we keep filling until the viewport overflows or the
              server has no more older events)

Tests cover all three trigger paths.

Co-authored-by: openhands <openhands@all-hands.dev>

* Fix paginated history follow-up issues

Co-authored-by: openhands <openhands@all-hands.dev>

* Harden paginated history responses

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 14:07:09 -07:00
Robert Brennanandopenhands 9c405c201a fix(conversations): key per-conversation queries by active backend (#275)
* fix(conversations): key per-conversation queries by active backend

Switching workspace local→cloud→local caused the conversation route to
fire "conversation does not exist or you do not have permission" until a
hard refresh, because:

* The `useUserConversation` query was keyed only by conversation id
  (`["user", "conversation", cid]`), not by the active backend.
* While the cloud backend was active, the still-subscribed query (or its
  30s `refetchInterval`) issued a `batchGetAppConversations([cid])` call
  that the cloud backend resolved to `null`, overwriting the cached
  entry under the shared key.
* On switching back to local, the route remount looked up
  `["user", "conversation", cid]` and found a fresh (`staleTime: 5m`)
  `null`, so it short-circuited to the not-available toast and
  redirected to /conversations.

This violated the documented invariant in active-backend-context.tsx
that long-lived queries must include the active backend's `id` and
`orgId` in their query keys so a backend/org switch becomes a brand-new
query (as `usePaginatedConversations` already does).

Apply the same convention to the three per-conversation hooks backed by
the AgentServerConversationService:

* `useUserConversation`
* `useSubConversations`
* `useBatchAppConversations` (currently unused but kept consistent)

After this, the conversation route's cache identity changes on every
backend/org switch, so stale cross-backend nulls cannot survive into the
next switch and no full page refresh is required.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: prettier-format updated query keys

Co-authored-by: openhands <openhands@all-hands.dev>

* test(use-websocket): stub WebSocket in query-params test to avoid CI flake

The MSW-backed "should support query parameters in WebSocket URL" test
intermittently times out at the 5s waitForConnection step because
`wsLink.broadcast()` from sibling tests leaks across the shared mock
server (see the file header note and AGENTS.md). The assertion only
needs to inspect the WebSocket URL, so switch to the same deterministic
stubbed-WebSocket pattern already used by `onClose` immediately below.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 13:11:41 -07:00
Robert Brennanandopenhands 6a54ff261b Add SELF_HOSTING.md: guide for hosting Agent Canvas on a VM (#273)
* Add SELF_HOSTING.md guide for VM deployments

Documents how to self-host Agent Canvas on a virtual machine with
defense-in-depth: cloud/host firewall + nginx HTTP basic auth + Let's
Encrypt TLS + SESSION_API_KEY on the agent server. Walks through
provisioning a VM, pointing a domain at it, and running
'npm run dev:dangerously-dockerless' behind nginx.

Co-authored-by: openhands <openhands@all-hands.dev>

* Refine SELF_HOSTING.md per review

- Replace ASCII diagram with a mermaid flowchart.
- Drop manual SESSION_API_KEY generation steps; the dev scripts auto-
  generate and persist the key at ~/.openhands/agent-canvas/session-api-key.txt.
- Move host firewall / ufw guidance into an 'Advanced: defense in depth'
  section at the end so the main flow stays focused on the cloud
  firewall as the primary perimeter.

Co-authored-by: openhands <openhands@all-hands.dev>

* Correct architecture diagram: nginx -> ingress proxy -> backends

The dev:dangerously-dockerless script runs a standalone ingress proxy
on 127.0.0.1:8000 that routes by path:
  /api/automation/* -> automation backend (:18001)
  /api/*, /sockets  -> agent server      (:18000)
  /*                -> Vite dev server    (:3001)

nginx only proxies to the ingress port, not to the three upstreams
directly. Update the mermaid diagram, the operational tip about not
exposing internal ports, and the 'ss -tlnp' check to reflect this.

Co-authored-by: openhands <openhands@all-hands.dev>

* Update SELF_HOSTING.md

* Update SELF_HOSTING.md

* Align detailed steps with the new Quickstart flow

The Quickstart added by 30d1385 has 5 steps: provision -> secure ->
run -> (optional) domain+nginx -> (optional) connect locally. Rework
the detailed sections to match 1:1 instead of the previous flow that
required a domain and nginx up front.

- Section 1 'Provision a machine' no longer combines the domain step;
  it now mentions both cloud VMs and dedicated hardware (Mac Mini etc).
- Section 2 'Secure the machine' reflects the default posture (only
  SSH inbound) instead of pre-opening 80/443. Ports 80/443 are opened
  later, in step 4.
- Section 3 'Run the server' merges install + SESSION_API_KEY note +
  run command into one place, and adds an SSH-tunnel subsection so
  step 4 is genuinely optional.
- Section 4 is now '(Optional) Get a domain and put nginx + Let's
  Encrypt + basic auth in front', combining the DNS/firewall opening
  with the nginx+certbot+basic-auth setup.
- New section 5 '(Optional) Connect your local Agent Canvas to the
  remote machine' walks through Manage backends -> Add a backend using
  the persisted session-api-key.txt as the Session API key, including
  the SSH-tunnel and basic-auth caveats.
- Updated the 'three lines of defense' summary to reflect that basic
  auth is now an optional layer that only applies if step 4 is done.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 13:06:53 -07:00
Robert Brennanandopenhands bae8e29f04 fix(agent-status): truncate status text instead of breaking per-character on narrow screens (#276)
When the chat input row was squeezed on small screens (Tools + model
name consume most of the width), the AgentStatus container shrank far
enough that 'whitespace-normal break-words' on the status label was
forced to break on every character, producing a one-character-per-line
'Running task' waterfall next to the pause button.

Drop flex-1 / whitespace-normal / break-words and use 'truncate'
instead so the label collapses to a single ellipsized line; the
existing title attribute still exposes the full text on hover.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 12:52:00 -07:00
Robert Brennanandopenhands b09f3a2275 add envrc to gitignore (#274)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 12:19:28 -07:00
Hiep Le 1c64a52d2c fix: hide Code tab when active backend is local (#272) 2026-05-11 02:04:36 +07:00
Robert Brennanandopenhands 3f55a57ff8 fix(home): show git repo picker on home when active backend is cloud (#270)
* fix(home): show git repo picker on home when active backend is cloud

Previously RepoConnector always rendered the local WorkspaceSelectionForm
because agent-canvas only ever talked to a local agent_server. Now that
a cloud backend can also be selected, branch on the active backend kind
and render RepositorySelectionForm (provider / repo / branch dropdowns)
when the active backend is cloud, leaving the workspace picker for
local backends.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(sidebar): show repo picker on +New Conversation when active backend is cloud

The sidebar 'New Conversation' popover previously always listed local
workspaces. When the active backend is cloud, list git repositories from
the connected provider instead. Clicking a repo launches a conversation
against its default branch (falling back to 'main'); no branch picker is
exposed — branches can still be switched once the conversation is running.

Split the existing local-mode component out into
new-conversation-button-local.tsx, add new-conversation-button-cloud.tsx,
and make new-conversation-button.tsx a thin dispatcher that picks the
right popover based on useActiveBackend().backend.kind.

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor(home): make RepositorySelectionForm.onRepoSelection optional

Address PR feedback on the no-op `onRepoSelection={() => {}}` callback in
RepoConnector. The form is fully self-contained — it owns its own Launch
button, calls createConversation, and navigates internally. The callback
exists only so callers that want to mirror the selection in their own
state can do so. Mark it optional, document the contract, and drop the
no-op from RepoConnector.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 11:53:44 -07:00
Robert Brennanandopenhands 3384aed454 Rearrange conversation right panel: tabs inside flush panel (#269)
* Rearrange conversation right panel: tabs inside flush panel

- Wrap tabs row + content in a single bordered, lighter (#25272D) panel
  on the conversation page, so the tabs read as part of the panel chrome.
- Remove the duplicate tab-name title bar inside each panel; refresh
  (changes) and Build (planner) actions move into the unified tabs row.
- Invert tab colors: inactive tabs become transparent and blend into
  the panel; the active tab uses the darker page color.
- Drop the conversation route's outer top/right padding and the
  ConversationMain top padding so the panel sits flush with the
  viewport edge; keep matching top padding on the chat panel for
  symmetry with the existing bottom padding.
- Delete the now-unused ConversationTabTitle component and its tests;
  update conversation-tab-content / conversation-tabs tests for the
  new structure (no title text, action buttons inside the tabs row).

Co-authored-by: openhands <openhands@all-hands.dev>

* Fix right panel tab regression coverage

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 11:36:45 -07:00
Robert Brennanandopenhands 8482aeda26 Add multi-step onboarding modal (#257)
* Add multi-step onboarding modal with agent/backend/LLM/hello steps

Adds a four-step welcome flow that appears on first visit (gated by an
`openhands-onboarded` localStorage flag) on the home route:

  0. Choose Agent — OpenHands selectable, Claude Code & Codex disabled
     with a "coming soon" note
  1. Check Backend — embeds the existing backend edit form, plus a
     live connection banner driven by useBackendsHealth
  2. Set up LLM — embeds the existing LlmSettingsScreen
  3. Say hello — pre-filled greeting input that launches a fresh
     conversation with no workspace

Each step lives in its own panel; advancing slides the rail
horizontally for the slide-in-from-the-right animation. A top progress
bar reflects the current step.

Also extracts a reusable BackendForm out of BackendFormModal so the
backend step can render the same form inline, and adds an
`onSaveSuccess` hook to LlmSettingsScreen so the onboarding flow can
auto-advance after saving.

Tests: 14 new tests cover the progress bar, completion hook, agent
chooser, and full modal navigation.

Co-authored-by: openhands <openhands@all-hands.dev>

* Fix onboarding slide rail layout & promote 'coming soon' note

Two related fixes for the onboarding modal:

1. Slide rail was using `width: 400%` + `translateX(-N * 100%)` on the
   rail itself. CSS translate percentages are relative to the
   *element's own* width, so on step 2 the rail shifted by its full
   2240px width — pushing all step content off-screen and leaving an
   empty modal sized to the tallest step. Reworked the rail so each
   slide now translates by `(index - currentStep) * 100%` of its own
   width, with inactive slides absolute-positioned (`inset-0`) inside
   a relative parent. Active slide drives the modal height; inactive
   slides no longer contribute vertical space, eliminating the
   funky-overhang behavior.

2. Moved the 'Support for other agents coming soon!' note from a tiny
   italic line at the bottom of the agent step to a prominent
   primary-tinted callout pinned to the top, with a Sparkles icon and
   bumped-up typography.

Tests assert per-slide transforms and active state instead of a
single rail transform; existing flow tests still cover the full
0 → 3 progression.

Co-authored-by: openhands <openhands@all-hands.dev>

* Polish onboarding LLM step: embed inline + default to Anthropic/Opus

The third onboarding step embedded `LlmSettingsScreen` as-is, which
brought along the screen's sticky `bg-base` save bar. In the modal
that rendered as a dark stripe overlapping the rest of the form, and
the existing per-step Next button created a confusing duplicate CTA
alongside Save Changes.

- Add an `embedded` prop to `SdkSectionPage` (and forward it through
  `LlmSettingsScreen`) that drops the sticky positioning + contrast
  background so the Save button just sits inline at the bottom of
  the form, blending with the modal panel.

- Add an `initialValueOverrides` prop to `SdkSectionPage` that wins
  over `useSettings`-derived defaults and pre-marks the overridden
  keys dirty so the Save button is enabled on mount. Use it from the
  onboarding LLM step to pin the model to
  `anthropic/claude-opus-4-5-20251101` per the spec, instead of the
  global OpenHands-prefixed default.

- Drop the redundant `onboarding-llm-next` button. Saving is now the
  primary advance action via the existing `onSaveSuccess` hook; a
  small "Skip for now" link remains for users who want to fill this
  in later.

Onboarding modal test updated to drive step 2 to step 3 via the skip
link.

Co-authored-by: openhands <openhands@all-hands.dev>

* Drive LLM step's save from the onboarding Next button

The previous polish pass left the LLM settings form's "Save Changes"
button visible inside the onboarding modal, with a separate "Skip for
now" link doing the advancing. That's the wrong shape for an
onboarding flow — every other step has a single primary Next button at
the modal footer, and the user expects clicking Next to commit the
form and advance.

- Add `hideSaveButton` and `onSaveControlChange` props to
  `SdkSectionPage` (forwarded through `LlmSettingsScreen`). The first
  suppresses the built-in Save button + the `pb-20` placeholder it
  reserved; the second hands the parent a stable `save()` callback
  plus `{ isSaving, isDirty }` state so it can render its own button.

- Rebuild `SetupLlmStep`'s footer as a single primary "Next" button
  matching the Backend step. Clicking Next:
    * triggers `saveControl.save()` if there are dirty fields;
      `onSaveSuccess` (wired to `onNext`) advances when the mutation
      resolves successfully.
    * falls through to `onNext()` when nothing is dirty, so users
      with already-configured settings aren't blocked.
  The Next button stays disabled while a save is in flight.

- Drop the previously-added "Skip for now" affordance — Next now
  covers the no-dirty-fields case.

- Restore the test selector to `onboarding-llm-next` so the modal
  test continues to drive step 2 → 3 the way users will.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 11:25:50 -07:00
Hiep Le 4b0f7d6b6e fix: keep top padding constant between Conversations and Automations (#268) 2026-05-10 23:43:07 +07:00
Hiep Le 18f7f985e8 fix: hide in-page settings navigation on desktop (#266) 2026-05-10 23:18:36 +07:00
Graham Neubigandopenhands 2d454b0405 Fix Vite build hygiene warnings (#264)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-10 07:29:03 -04:00
Hiep Le 4683fa90ae fix: always load public skills on global Skills page (#263) 2026-05-10 17:57:33 +07:00
Hiep Le fdaf3873fe fix: align nav link default text color with settings page nav (#261) 2026-05-10 16:06:24 +07:00
Hiep Le 137fbae87f fix: pass container workspaces path as VITE_WORKING_DIR (#259) 2026-05-10 15:56:33 +07:00
Robert Brennanandopenhands 8017bb5e1b Fix conversation right pane clipping when divider dragged far right (#256)
- Remove min-w-max from the right-pane inner wrapper in conversation-main
  so it can shrink with its container instead of forcing intrinsic width
  past the visible area.
- Clamp the persisted left-pane width in useResizablePanels against the
  current min/max so a stale localStorage value can't push the divider
  out of bounds on load.
- Drop the md:p-3 padding on /conversations* in root-layout, matching
  the existing /automations* behavior, so the conversation page renders
  edge-to-edge.

Includes regression tests for all three changes.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-09 18:13:06 -07:00
Robert Brennanandopenhands 65e5a3792b perf(frontend): shrink eager dev/prod graph with lazy boundaries (Tier 1) (#238)
Investigation showed the dev server was issuing hundreds of requests per
navigation because (a) the 1 MB src/i18n/translation.json was statically
imported into every chunk that touched i18n, (b) several conditionally-
rendered modals/overlays were eagerly imported by the always-mounted
root layout, (c) the terminal tab was statically imported alongside the
already-lazy peer tabs, and (d) the conversation-events/chat barrel
(plus its event-message-components/index.ts) fanned the dev graph out
to ~15 unrelated sibling modules whenever ChatInterface needed Messages.

Changes:

1. Move translation.json into src/i18n/resources.ts and re-export
   translationResources from src/i18n/index.ts via a tree-shakable
   'export … from' with a /* @__PURE__ */ annotation, so rollup drops
   the JSON from the app build (custom-toast-handlers chunk:
   909 KB -> 74 KB).

2. Lazy-load Terminal in conversation-tab-content.tsx alongside the
   other six tabs; xterm + addon-fit + xterm.css now ship as a
   separate ~336 KB chunk loaded only when the tab is opened.

3. Lazy-load conditional UI from the eager root graph:
   - root-layout.tsx: AnalyticsConsentFormModal, AlertBanner,
     EnvironmentSwitchOverlay
   - sidebar.tsx: SettingsModal
   - root.tsx: AgentServerConnectionForm
   Each is wrapped in <Suspense fallback={null}>.

4. Split environment-switch-overlay into a tiny
   environment-switch-store.ts (constants, triggers, snapshot) and the
   React component file. backend-selector.tsx imports trigger helpers
   from the store so the eagerly-mounted sidebar path no longer drags
   the overlay's render code in. The overlay file re-exports the store
   API for back-compat with existing tests.

5. Replace barrel imports of #/components/conversation-events/chat (and
   its event-message-components barrel) with deep paths in
   chat-interface.tsx, shared-conversation.tsx, use-filtered-events.ts,
   messages.tsx, and event-message.tsx. Conversation chunk:
   728 KB -> 392 KB.

Tests: build, typecheck, and lint pass; 39 affected test files (260
tests) pass. Updated root-layout.test.tsx to await findByTestId for the
now-lazy analytics consent modal. Pre-existing failures
(backend-selector.test.tsx Dropdown crash, root-layout-refetch.test.tsx,
library-namespace.test.ts under full-suite load, two unrelated prettier
errors) are unchanged.

AGENTS.md updated with the new constraints so future edits don't
accidentally re-bundle translation.json or import the chat barrel
inside src/.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-09 17:37:02 -07:00
Robert Brennanandopenhands 3f55620baf Fix/refresh convo list on create (#251)
* feat: always request a git worktree when creating conversations

PR OpenHands/software-agent-sdk#3180 adds a 'worktree' boolean to the
agent-server's StartConversationRequest. When true, the runtime creates a
dedicated git worktree on an 'openhands/<conversation_id>' branch so agent
changes stay isolated from the original checkout.

Always set worktree: true on the local /api/conversations payload built by
buildStartConversationRequest. The cloud SaaS path (AppConversationStartRequest)
is a separate app-backend contract and is not affected by that PR.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: refresh start-tasks query when a conversation is created

useCreateConversation already invalidates the paginated conversations
list (matching the sidebar's ["user", "conversations", "paginated", ...]
key by prefix), so on local backends the new conversation pops into the
sidebar immediately. The cloud SaaS path, however, returns a start task
that is surfaced via useStartTasks (queryKey ["start-tasks", "search"])
which has no refetchInterval, so an in-flight task wouldn't appear in
the conversation list until the page was reloaded.

Invalidate the start-tasks query alongside the conversations one so the
list refreshes immediately for both ready conversations and provisioning
tasks.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: invalidate start-tasks query when a conversation is deleted

useDeleteConversation already invalidates ["user", "conversations"]
on settle, which (by prefix match) refetches the sidebar's paginated
conversation list. Mirror the create-side fix and also invalidate
["start-tasks"] so cloud-SaaS in-flight tasks are removed from the
panel immediately, regardless of whether the deleted item was a ready
conversation or a still-provisioning start task.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-09 16:51:09 -07:00