mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
56 Commits
openhands/
...
cli-ctrl-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb348a5f3d | ||
|
|
099dcb787f | ||
|
|
b3034a0d75 | ||
|
|
459e224d37 | ||
|
|
97f13b7100 | ||
|
|
6ecaca5b3c | ||
|
|
5351702d3a | ||
|
|
e51685dab4 | ||
|
|
b85cc0c716 | ||
|
|
7ef1720b5d | ||
|
|
a6385b4059 | ||
|
|
7cfe667a3f | ||
|
|
6e8be827b8 | ||
|
|
2ccc611e7c | ||
|
|
1f7dec4d94 | ||
|
|
966e4ae990 | ||
|
|
231019974c | ||
|
|
d246ab1a21 | ||
|
|
15c207c401 | ||
|
|
cf21cfed6c | ||
|
|
12d57df6ac | ||
|
|
3239eb4027 | ||
|
|
9be673d553 | ||
|
|
7272eae758 | ||
|
|
ec670cd130 | ||
|
|
31702bf46b | ||
|
|
5894d2675e | ||
|
|
59a992c0fb | ||
|
|
1939bd0fda | ||
|
|
58e690ef75 | ||
|
|
97403dfbdb | ||
|
|
2fc31e96d0 | ||
|
|
6558b4f97d | ||
|
|
12d6da8130 | ||
|
|
38f2728cfa | ||
|
|
fab48fe864 | ||
|
|
a196881ab0 | ||
|
|
ca2c9546ad | ||
|
|
704fc6dd69 | ||
|
|
6630d5dc4e | ||
|
|
0e7fefca7e | ||
|
|
4020448d64 | ||
|
|
2fdd4d084a | ||
|
|
aba5d54a86 | ||
|
|
6710a39621 | ||
|
|
fccc6f3196 | ||
|
|
7447cfdb3d | ||
|
|
297af05d53 | ||
|
|
b8f387df94 | ||
|
|
fc67f39b74 | ||
|
|
bc8922d3f9 | ||
|
|
37d58bba4d | ||
|
|
037a2dca8f | ||
|
|
b5920eece6 | ||
|
|
a81bef8cdf | ||
|
|
450aa3b527 |
4
.github/scripts/update_pr_description.sh
vendored
4
.github/scripts/update_pr_description.sh
vendored
@@ -13,9 +13,9 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.all-hands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
|
||||
|
||||
@@ -71,6 +71,14 @@ jobs:
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Verify binary files exist
|
||||
run: |
|
||||
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
|
||||
echo "❌ No binaries found to upload!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Found binaries to upload."
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/ghcr-build.yml
vendored
2
.github/workflows/ghcr-build.yml
vendored
@@ -37,7 +37,6 @@ jobs:
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
@@ -46,7 +45,6 @@ jobs:
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ghcr.io/openhands/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
fi
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.59-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -66,10 +66,10 @@ See the [uv installation guide](https://docs.astral.sh/uv/getting-started/instal
|
||||
**Launch OpenHands**:
|
||||
```bash
|
||||
# Launch the GUI server
|
||||
uvx --python 3.12 --from openhands-ai openhands serve
|
||||
uvx --python 3.12 openhands serve
|
||||
|
||||
# Or launch the CLI
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
uvx --python 3.12 openhands
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
|
||||
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.59
|
||||
docker.openhands.dev/openhands/openhands:0.60
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
302
code_design.md
302
code_design.md
@@ -1,302 +0,0 @@
|
||||
# V1 API Redesign – Working Notes (tracking)
|
||||
|
||||
Purpose: Track current V1 implementation status for the three proposal aspects and enumerate V1 REST routes. Focus on app-server (V1) that orchestrates remote agent-server from agent-sdk. Ignore V0 legacy where not on the V1 execution path.
|
||||
|
||||
Sources read in repo:
|
||||
- openhands/server/app.py (router mounting)
|
||||
- openhands/app_server/v1_router.py (V1 API surface)
|
||||
- app-server packages: app_conversation, event, event_callback, sandbox, user, services, config
|
||||
- Frontend calls under frontend/src/api to validate route usage
|
||||
|
||||
External: agent-sdk (agent-server + sdk) is the V1 core. In this tree we import:
|
||||
- openhands.agent_server.* (models, utils)
|
||||
- openhands.sdk.* (LLM, workspace, secrets)
|
||||
|
||||
---
|
||||
|
||||
## 1) UserContext
|
||||
|
||||
Status
|
||||
- UserContext abstraction exists and is used via FastAPI DI.
|
||||
- Interface: openhands/app_server/user/user_context.py
|
||||
- get_user_id(), get_user_info(), get_authenticated_git_url(), get_latest_token(provider_type), get_secrets()
|
||||
- Implementations:
|
||||
- AuthUserContext (user-auth backed): openhands/app_server/user/auth_user_context.py
|
||||
- Bridges to legacy user auth storage to obtain settings and provider tokens as needed, but keeps tokens out of route signatures
|
||||
- Internally uses ProviderHandler to derive provider services and latest tokens
|
||||
- SpecifyUserContext (admin/sandbox/internal flows): openhands/app_server/user/specifiy_user_context.py
|
||||
- Request-scoped override carrying a specific user_id (or None for admin)
|
||||
- Injectors:
|
||||
- AuthUserContextInjector provides request-scoped UserContext from request-auth.
|
||||
|
||||
Observations
|
||||
- agent-sdk/agent-server have no concept of user_id; user is an app concern.
|
||||
- app-server introduces persistence for conversation metadata in DB (SQLAlchemy models) with user_id columns, but these are used via services that consume UserContext.
|
||||
- New V1 routes do not carry user_id in path or query; scoping is enforced inside services via UserContext (see SQLAppConversationInfoService._secure_select()).
|
||||
|
||||
Auth/token threading in V1 app-server
|
||||
- Tokens are not threaded through route signatures.
|
||||
- Provider tokens are accessed via UserContext methods, often converted to SecretSource for the agent runtime.
|
||||
- For agent-server communication, a short-lived session API key is used via header X-Session-API-Key.
|
||||
|
||||
Org risk and recommendation
|
||||
- Enterprise is adding organizations (org_id) linking users to orgs. Do NOT introduce org_id into routes.
|
||||
- Extend context instead of paths/signatures:
|
||||
- Option A: add optional org_id to UserContext.get_user_info() result and to SQL filter logic; record created_by_org_id in metadata.
|
||||
- Option B: introduce a separate OrganizationContext resolved by DI and consumed by services alongside UserContext.
|
||||
- Ensure all service-layer DB queries filter by (user_id OR memberships via org_id) based on policy. Keep route signatures unchanged.
|
||||
|
||||
Actionable follow-ups
|
||||
- Add org-aware filtering in SQL services without affecting routes.
|
||||
- Keep provider tokens and org scoping behind UserContext/OrganizationContext.
|
||||
|
||||
Key references
|
||||
- user_context.py, auth_user_context.py, specifiy_user_context.py
|
||||
- app_conversation/sql_app_conversation_info_service.py (uses get_user_id() for row-level filtering)
|
||||
- event/filesystem_event_service.py (permissioning via app_conversation_info_service)
|
||||
|
||||
|
||||
Audit summary
|
||||
- Routers
|
||||
- /api/v1/users: user_router.py uses DI UserContext.get_user_info(); no user_id exposure
|
||||
- /api/v1/app-conversations: app_conversation_router.py uses service injectors; stream-start pins UserContext in InjectorState for streaming; no user_id exposure
|
||||
- /api/v1/events: event_router.py uses EventService via DI; no user_id exposure
|
||||
- /api/v1/webhooks: webhook_router.py validates sandbox via X-Session-API-Key, uses as_admin() for webhook auth, and resolves per-user context via injector.get_for_user(user_id) for JWS-secret fetch; no user_id in route signatures
|
||||
- Services
|
||||
- SQLAppConversationInfoService scopes by UserContext.get_user_id() within _secure_select(), count, get, batch_get, save
|
||||
- SQLAppConversationStartTaskService injector resolves user_id once from UserContext and binds it to service instance; no route exposure
|
||||
- LiveStatusAppConversationService uses UserContext for authorship/tokens; interacts with Agent Server using sandbox session API key
|
||||
- Legacy shims still mounted and used in V1 flows
|
||||
- server/routes/conversation.py and manage_conversations.py continue to use Depends(get_user_id); treat them as compatibility shims only; avoid new feature work there
|
||||
|
||||
---
|
||||
|
||||
## 2) ConversationPaths
|
||||
|
||||
Status
|
||||
- agent-sdk and agent-server store conversation data; app-server keeps metadata in DB and events in filesystem.
|
||||
- No user_id is embedded in filesystem paths in V1 app-server.
|
||||
- FilesystemEventService stores events under: {persistence_dir}/v1/events/{conversation_id}/timestamp_kind_eventId
|
||||
- Therefore, V0 concern about user_id in conversation paths is not present in V1 app-server.
|
||||
|
||||
Validation
|
||||
- FilesystemEventService._ensure_events_dir(), _get_event_files_by_pattern() demonstrate directory layout without user_id.
|
||||
- All user scoping happens at service layer by checking conversation ownership via DB service (batch_get_app_conversation_info) before returning file-based events.
|
||||
|
||||
Recommendation
|
||||
- Keep user/org scoping out of paths. If orgs are added, continue enforcing access via DB/service checks, not via path namespacing.
|
||||
|
||||
Key references
|
||||
- app_server/event/filesystem_event_service.py
|
||||
- app_server/app_conversation/sql_app_conversation_info_service.py
|
||||
|
||||
---
|
||||
|
||||
## 3) TokenSource → LookupSecret(SecretSource)
|
||||
|
||||
Status
|
||||
- agent-sdk’s equivalent boundary is SecretSource with implementations:
|
||||
- StaticSecret for literal values
|
||||
- LookupSecret for remote fetch
|
||||
|
||||
Token sweep (direct usage in routes/services)
|
||||
- V1 app-server
|
||||
- No provider tokens in route signatures. Provider access goes through UserContext → ProviderHandler and is consumed as SecretSource (StaticSecret or LookupSecret).
|
||||
- X-Session-API-Key appears only for agent-server calls (headers) and sandbox auth in webhooks; not a provider token. Required to authorize runtime access.
|
||||
- X-Access-Token (JWS) appears only in webhook secret fetch flow; scoped to user_id and provider_type (and can include org_id in future). Not required in public routes; only used by sandboxes to retrieve secrets.
|
||||
- Legacy routes still mounted
|
||||
- /api/user/* expects provider_tokens via Depends(get_provider_tokens) plus access_token (legacy external auth) and user_id. These are the only places exposing provider token inputs at the REST surface today.
|
||||
- Guidance: keep them as compatibility endpoints. Do not add new surfaces that accept provider tokens. Prefer V1 SecretSource/JWS approach.
|
||||
- Services
|
||||
- SQL services do not pass provider tokens; they consume UserContext.user_id for row-level filtering only.
|
||||
- LiveStatusAppConversationService uses JWT service to create X-Access-Token for LookupSecret and uses sandbox X-Session-API-Key to talk to agent-server; no provider tokens in signatures.
|
||||
|
||||
Access token in routes?
|
||||
- We do not need an access token in public V1 routes. The JWS access token (X-Access-Token) is strictly an internal sandbox-to-app-server credential for GET /api/v1/webhooks/secrets.
|
||||
- For user-initiated calls, standard app auth + DI is sufficient; provider tokens should never be threaded through public route signatures.
|
||||
|
||||
- app-server V1 maps provider tokens into SecretSource:
|
||||
- In LiveStatusAppConversationService._build_start_conversation_request_for_user():
|
||||
- If web_url is configured, constructs a LookupSecret to GET /api/v1/webhooks/secrets with X-Access-Token (JWS) that includes user_id and provider_type (scoped and expirable)
|
||||
- Else falls back to StaticSecret with latest provider token from UserContext
|
||||
- Therefore, token refresh/leakage is solved at the boundary: agent runtime calls back via LookupSecret; route signatures remain free of tokens.
|
||||
|
||||
Other tokens/headers in signatures
|
||||
- X-Session-API-Key: agent-server session API key for starting conversations via app-server → agent-server POSTs.
|
||||
- X-Access-Token: app-server-issued JWS for sandbox to retrieve secrets via /api/v1/webhooks/secrets.
|
||||
- No provider_tokens or user_id appear in public REST route signatures.
|
||||
|
||||
Recommendation
|
||||
- Keep all external-provider token logic behind SecretSource and JWT/JWS.
|
||||
- With orgs, add org_id into the JWS claims when appropriate, and enforce in webhook secret retrieval by validating both user and org scopes.
|
||||
|
||||
Key references
|
||||
- app_conversation/live_status_app_conversation_service.py (GIT_TOKEN secret construction)
|
||||
- event_callback/webhook_router.py (GET /secrets; validates JWS and fetches provider tokens via per-user DI)
|
||||
- user/auth_user_context.py (ProviderHandler usage is internal)
|
||||
|
||||
---
|
||||
|
||||
## V1 REST Routes (current)
|
||||
|
||||
Mounted under /api/v1 via openhands/app_server/v1_router.py
|
||||
|
||||
- /api/v1/app-conversations (app_conversation_router)
|
||||
- GET /search: filter by title/created_at/updated_at; pagination via page_id, limit
|
||||
- GET /count: same filters, returns count
|
||||
- GET /: batch get by ids[]=UUID
|
||||
- POST /: start task (returns AppConversationStartTask); uses background processing and X-Session-API-Key to talk to agent-server
|
||||
- POST /stream-start: streams AppConversationStartTask updates until READY/ERROR
|
||||
- GET /start-tasks/search: filter by conversation_id; sort order; pagination
|
||||
- GET /start-tasks/count
|
||||
- GET /start-tasks: batch get by ids[]=UUID
|
||||
|
||||
- /api/v1/events (event_router)
|
||||
- GET /search: filters (conversation_id, kind, timestamp ranges), sort, pagination
|
||||
- GET /count: same filters, count
|
||||
- GET /: batch get by id[]=str (UUIDs)
|
||||
|
||||
- /api/v1/sandboxes (sandbox_router)
|
||||
- GET /search: pagination
|
||||
- GET /: batch get by id[]=str
|
||||
- POST /: start sandbox (optional sandbox_spec_id)
|
||||
- POST /{sandbox_id}/pause
|
||||
- POST /{sandbox_id}/resume
|
||||
- DELETE /{id}: delete sandbox (NB: path parameter name differs from function param; consider standardizing to {sandbox_id})
|
||||
|
||||
- /api/v1/webhooks (webhook_router)
|
||||
- POST /{sandbox_id}/conversations: upsert conversation info from agent-server callback
|
||||
- POST /{sandbox_id}/events/{conversation_id}: append events from agent-server callback
|
||||
- GET /secrets: return provider secret value for scoped JWS (X-Access-Token)
|
||||
|
||||
- /api/v1/users (user_router)
|
||||
- GET /me: returns current authenticated user info
|
||||
|
||||
Auth primitives in routes
|
||||
- No user_id in REST paths.
|
||||
- No provider_tokens in route signatures.
|
||||
- Authentication/authorization via DI-provided contexts and header tokens (session or access) where necessary.
|
||||
|
||||
---
|
||||
|
||||
## Legacy/V0 surface still present (for UI compatibility)
|
||||
|
||||
Below is a concise mapping of legacy endpoints that the frontend still calls, and how they relate to V1. Some are intentionally retained because they provide functionality not yet covered by the new V1 surfaces. All items verified against the current codebase.
|
||||
|
||||
- /api/user/* (legacy Git/user provider APIs)
|
||||
- Implemented in: openhands/server/routes/git.py
|
||||
- Frontend usage: frontend/src/api/git-service/*.ts and suggestions-service.api.ts
|
||||
- Purpose: repository discovery, branches, installations, microagents listing/content, suggested tasks
|
||||
- Status for V1: Retained. These serve as integration-oriented endpoints for git providers and repo scanning. No direct V1 replacement yet. They remain part of the V1 experience and are mounted alongside /api/v1.
|
||||
|
||||
- /api/conversations/{conversation_id}/events and related conversation endpoints (legacy conversation runtime APIs)
|
||||
- Implemented in: openhands/server/routes/conversation.py
|
||||
- Frontend V1 usage: frontend/src/api/conversation-service/v1-conversation-service.api.ts uses POST /api/conversations/{conversationId}/events to send messages
|
||||
|
||||
Org policy without schema changes
|
||||
- Scope: Avoid org_id in routes or schemas. Apply org-aware access exclusively in DI/service layer and JWS claims.
|
||||
- Strategy: Compute allowed_user_ids for the active context. Services filter by created_by_user_id in StoredConversationMetadata using either:
|
||||
- user_id from UserContext when no org is active, or
|
||||
- a set of user_ids derived from OrganizationContext memberships when an org is active.
|
||||
- No table changes are required because we filter on user_id. Organization membership is resolved externally (e.g., enterprise DB) and injected via DI.
|
||||
|
||||
DI structure
|
||||
- OrganizationContext (DI, optional)
|
||||
- active_org_id: str | None (selected org or None)
|
||||
- member_user_ids(): set[str] (users in selected org)
|
||||
- is_member(user_id: str) -> bool
|
||||
- Default OH implementation returns no active_org_id and empty membership, so behavior equals current user-only scoping.
|
||||
- Enterprise can provide an OrganizationContext injector that reads org selection from auth/session and resolves memberships from its own DB.
|
||||
|
||||
Service scoping wrappers (no schema change)
|
||||
- Wrap SQLAppConversationInfoService with OrgScopedAppConversationInfoService:
|
||||
- search/count/get/batch_get: if active_org_id, filter where user_id IN member_user_ids(); else filter by current user_id.
|
||||
- save: assert created_by_user_id belongs to current user or org membership (policy: allow only current user as author; org membership governs visibility, not authorship).
|
||||
- Wrap SQLAppConversationStartTaskService similarly using created_by_user_id.
|
||||
- Event access: EventService should validate visibility via AppConversationInfoService prior to reading from filesystem; reuse the same org-aware filter.
|
||||
|
||||
Pseudocode (service wrapper)
|
||||
- def _user_ids_scope():
|
||||
- org_ctx = OrganizationContext(); user_ctx = UserContext()
|
||||
- if org_ctx.active_org_id: return org_ctx.member_user_ids()
|
||||
- else: return { await user_ctx.get_user_id() }
|
||||
- For queries: query.where(StoredConversationMetadata.user_id.in_(scope))
|
||||
|
||||
JWS claim integration (secrets)
|
||||
- X-Access-Token JWS may optionally include org_id claim when an org is active.
|
||||
- GET /api/v1/webhooks/secrets should verify org_id claim via OrganizationContext and enforce that the requested secret belongs to a user within org membership.
|
||||
- This keeps org scope entirely in DI/JWS logic; no route or schema change needed.
|
||||
|
||||
Legacy endpoints
|
||||
- /api/user/* remain as compatibility surfaces and accept provider_tokens/access_token today; do not add org_id to their routes. If org-aware behavior is needed there, enterprise can wrap or replace them with V1 endpoints that resolve provider tokens via DI and JWS instead of raw provider_tokens.
|
||||
|
||||
Risks
|
||||
- Reintroducing user_id or org_id into route signatures through new endpoints. Mitigate by requiring DI-only scoping for all new features.
|
||||
- Inconsistent scoping across services. Mitigate by centralizing scope computation (_user_ids_scope) and reusing it in all service wrappers.
|
||||
- Performance on IN clauses with large orgs. Mitigate with caching of member_user_ids and pagination limits; optionally implement server-side membership expansion via join in enterprise layer.
|
||||
|
||||
Rollout plan
|
||||
- Phase 1 (no schema changes):
|
||||
- Implement OrganizationContext injector (default no-op in OH; enterprise provides real one).
|
||||
- Add OrgScoped wrappers for AppConversationInfoService, AppConversationStartTaskService, and EventService.
|
||||
- Add optional org_id claim to JWS token issuance path in LiveStatusAppConversationService and verify in webhook_router.get_secret.
|
||||
- Phase 2: Monitor perf; if needed, enterprise may introduce derived indices or membership-materialized views on its side without touching OH schemas.
|
||||
|
||||
- How V1 wiring works:
|
||||
- get_remote_runtime_config at GET /api/conversations/{conversation_id}/config detects V1 sessions (UUID) and maps to sandbox_id + session_api_key so that a V1 session can still use these endpoints
|
||||
- add_event and add_message forward events to the appropriate runtime (legacy or mapped V1)
|
||||
- Status for V1: Retained. These are part of the V1 runtime interaction shim and are intentionally kept to avoid duplicating runtime event endpoints under /api/v1.
|
||||
|
||||
- /api (legacy conversation management)
|
||||
- Implemented in: openhands/server/routes/manage_conversations.py
|
||||
- Purpose: V0 session lifecycle management and metadata; has shims to include V1 results when possible
|
||||
- Example: GET /api/conversations/{conversation_id} tries V1 via AppConversationService first, else falls back to V0
|
||||
- POST /api/conversations creates V0 sessions (distinct from /api/v1/app-conversations which starts a V1 conversation via agent-server)
|
||||
- Status for V1: Mixed. Some endpoints act as shims for compatibility and aggregation; new V1 creation flows are at /api/v1/app-conversations. These legacy endpoints are mounted and available but should be considered compatibility layers.
|
||||
|
||||
Notes
|
||||
- Routing: openhands/server/app.py conditionally mounts v1_router alongside legacy routers when server_config.enable_v1 is true. The frontend calls a mix of /api/v1/* and legacy /api/* or /api/user/* where necessary. This is expected during the transition.
|
||||
- Frontend usage verification:
|
||||
- frontend/src/api/conversation-service/v1-conversation-service.api.ts
|
||||
- sendMessage() posts to /api/conversations/{id}/events (legacy path) using V1 conversationUrl/session key
|
||||
- getVSCodeUrl(), pauseConversation(), and uploadFile() talk directly to the agent-server via conversationUrl (endpoints provided by agent-server)
|
||||
- frontend/src/api/suggestions-service/suggestions-service.api.ts calls /api/user/suggested-tasks
|
||||
- Recommendation: keep these legacy endpoints stable while we evaluate which ones should get V1-native equivalents. Where legacy endpoints are used in V1 flows (e.g., sending messages), they are effectively part of the V1 surface and should be documented as such.
|
||||
- Org risk: even for retained legacy endpoints, avoid adding org_id to paths. Apply org scoping via DI/service checks and JWS where needed.
|
||||
|
||||
---
|
||||
|
||||
## Open risks and proposed refactor plan (orgs)
|
||||
|
||||
1) Data model
|
||||
- Do not add org_id to OpenHands models or schemas at this stage.
|
||||
- Keep storage keyed by user_id only; org-aware access should be enforced purely via DI/service policy (UserContext/Organization policy), never via new columns or route params.
|
||||
|
||||
2) Context and DI
|
||||
- Option A: Extend UserContext.get_user_info() to include org memberships and an active org_id (if selected).
|
||||
- Option B: Add OrganizationContext via DI and inject alongside UserContext. Services consult both for scoping.
|
||||
|
||||
3) Service filters
|
||||
- Update SQLAppConversationInfoService._secure_select() to filter by either user_id or org memberships as policy dictates. Avoid touching route signatures.
|
||||
|
||||
4) Secret scoping
|
||||
- When issuing JWS for /webhooks/secrets, include org_id claim and verify it on fetch. Ensure provider token resolution respects org policies.
|
||||
|
||||
5) Endpoint contracts
|
||||
- Keep V1 routes as-is (no org_id in path). Avoid proliferating new route variants.
|
||||
|
||||
6) Cleanup
|
||||
- Gradually remove V0 routes once frontend migrates fully to V1 equivalents.
|
||||
|
||||
---
|
||||
|
||||
## Quick pointers (file paths)
|
||||
- V1 router aggregator: openhands/app_server/v1_router.py
|
||||
- UserContext: openhands/app_server/user/user_context.py
|
||||
- AuthUserContext: openhands/app_server/user/auth_user_context.py
|
||||
- Admin override: openhands/app_server/user/specifiy_user_context.py
|
||||
- V1 DB services: app_conversation/sql_app_conversation_info_service.py, sql_app_conversation_start_task_service.py
|
||||
- Events: app_server/event/filesystem_event_service.py
|
||||
- Secrets/lookup: app_server/event_callback/webhook_router.py, app_conversation/live_status_app_conversation_service.py
|
||||
|
||||
This file is a living document – update as implementations evolve.
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
32
enterprise/poetry.lock
generated
32
enterprise/poetry.lock
generated
@@ -5737,7 +5737,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a3"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5758,14 +5758,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.59.0"
|
||||
version = "0.0.0-post.5456+15c207c40"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5805,9 +5805,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-tools"}
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5863,7 +5863,7 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a3"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5886,14 +5886,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a3"
|
||||
version = "1.0.0a4"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5913,9 +5913,9 @@ pydantic = ">=2.11.7"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
|
||||
79
evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py
Normal file
79
evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_final_reports(base_dir, pattern=None):
|
||||
base_path = Path(base_dir)
|
||||
if not base_path.exists():
|
||||
raise FileNotFoundError(f'Base directory does not exist: {base_dir}')
|
||||
|
||||
# Find all final_report.json files
|
||||
all_reports = list(base_path.rglob('final_report.json'))
|
||||
|
||||
if pattern is None:
|
||||
return all_reports
|
||||
|
||||
# Filter by pattern
|
||||
filtered_reports = []
|
||||
for report in all_reports:
|
||||
# Get relative path from base_dir for matching
|
||||
rel_path = report.relative_to(base_path)
|
||||
if fnmatch.fnmatch(str(rel_path), pattern):
|
||||
filtered_reports.append(report)
|
||||
|
||||
return filtered_reports
|
||||
|
||||
|
||||
def collect_resolved_ids(report_files):
|
||||
id_counter = Counter()
|
||||
|
||||
for report_file in report_files:
|
||||
with open(report_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
if 'resolved_ids' not in data:
|
||||
raise KeyError(f"'resolved_ids' key not found in {report_file}")
|
||||
resolved_ids = data['resolved_ids']
|
||||
id_counter.update(resolved_ids)
|
||||
|
||||
return id_counter
|
||||
|
||||
|
||||
def get_skip_ids(id_counter, threshold):
|
||||
return [id_str for id_str, count in id_counter.items() if count >= threshold]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Compute SKIP_IDS from resolved IDs in final_report.json files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'threshold',
|
||||
type=int,
|
||||
help='Minimum number of times an ID must be resolved to be skipped',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--base-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
help='Base directory to search for final_report.json files (default: evaluation/evaluation_outputs/outputs)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pattern',
|
||||
default=None,
|
||||
help='Glob pattern to filter paths (e.g., "*Multi-SWE-RL*/**/*gpt*")',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
report_files = find_final_reports(args.base_dir, args.pattern)
|
||||
id_counter = collect_resolved_ids(report_files)
|
||||
|
||||
skip_ids = get_skip_ids(id_counter, args.threshold)
|
||||
skip_ids = [s.replace('/', '__').replace(':pr-', '-') for s in skip_ids]
|
||||
skip_ids = ','.join(sorted(skip_ids))
|
||||
print(skip_ids)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -747,10 +747,14 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
subset = dataset[dataset[filter_column].isin(selected_ids)]
|
||||
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
|
||||
return subset
|
||||
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
|
||||
skip_ids = [id for id in os.environ.get('SKIP_IDS', '').split(',') if id]
|
||||
if len(skip_ids) > 0:
|
||||
logger.info(f'Dataset size before filtering: {dataset.shape[0]} tasks')
|
||||
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
|
||||
return dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'SKIP_IDS:\n{skip_ids}')
|
||||
filtered_dataset = dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'Dataset size after filtering: {filtered_dataset.shape[0]} tasks')
|
||||
return filtered_dataset
|
||||
return dataset
|
||||
|
||||
|
||||
@@ -768,6 +772,11 @@ if __name__ == '__main__':
|
||||
default='test',
|
||||
help='split to evaluate on',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--filter_dataset_after_sampling',
|
||||
action='store_true',
|
||||
help='if provided, filter dataset after sampling instead of before',
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
|
||||
@@ -777,10 +786,24 @@ if __name__ == '__main__':
|
||||
logger.info(f'Loading dataset {args.dataset} with split {args.split} ')
|
||||
dataset = load_dataset('json', data_files=args.dataset)
|
||||
dataset = dataset[args.split]
|
||||
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
swe_bench_tests = dataset.to_pandas()
|
||||
|
||||
# Determine filter strategy based on flag
|
||||
filter_func = None
|
||||
if args.filter_dataset_after_sampling:
|
||||
# Pass filter as callback to apply after sampling
|
||||
def filter_func(df):
|
||||
return filter_dataset(df, 'instance_id')
|
||||
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks (filtering will occur after sampling)'
|
||||
)
|
||||
else:
|
||||
# Apply filter before sampling
|
||||
swe_bench_tests = filter_dataset(swe_bench_tests, 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
@@ -810,7 +833,9 @@ if __name__ == '__main__':
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
print(f'### OUTPUT FILE: {output_file} ###')
|
||||
instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
|
||||
instances = prepare_dataset(
|
||||
swe_bench_tests, output_file, args.eval_n_limit, filter_func=filter_func
|
||||
)
|
||||
|
||||
if len(instances) > 0 and not isinstance(
|
||||
instances['FAIL_TO_PASS'][instances['FAIL_TO_PASS'].index[0]], str
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
MODEL=$1 # eg your llm config name in config.toml (eg: "llm.claude-3-5-sonnet-20241022-t05")
|
||||
EXP_NAME=$2 # "train-t05"
|
||||
EVAL_DATASET=$3 # path to original dataset (jsonl file)
|
||||
N_WORKERS=${4:-64}
|
||||
N_RUNS=${5:-1}
|
||||
MAX_ITER=$4
|
||||
N_WORKERS=${5:-64}
|
||||
N_RUNS=${6:-1}
|
||||
EVAL_LIMIT=${7:-}
|
||||
SKIP_IDS_THRESHOLD=$8
|
||||
SKIP_IDS_PATTERN=$9
|
||||
INPUT_SKIP_IDS=${10}
|
||||
FILTER_DATASET_AFTER_SAMPLING=${11:-}
|
||||
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
@@ -17,6 +23,7 @@ export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
echo "EVAL_DATASET: $EVAL_DATASET"
|
||||
echo "INPUT_SKIP_IDS: $INPUT_SKIP_IDS"
|
||||
# Generate DATASET path by adding _with_runtime_ before .jsonl extension
|
||||
DATASET="${EVAL_DATASET%.jsonl}_with_runtime_.jsonl" # path to converted dataset
|
||||
|
||||
@@ -35,9 +42,6 @@ else
|
||||
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
|
||||
fi
|
||||
|
||||
#EVAL_LIMIT=3000
|
||||
MAX_ITER=100
|
||||
|
||||
|
||||
# ===== Run inference =====
|
||||
source "evaluation/utils/version_control.sh"
|
||||
@@ -69,17 +73,52 @@ function run_eval() {
|
||||
--dataset $DATASET \
|
||||
--split $SPLIT"
|
||||
|
||||
# Conditionally add filter flag
|
||||
if [ "$FILTER_DATASET_AFTER_SAMPLING" = "true" ]; then
|
||||
COMMAND="$COMMAND --filter_dataset_after_sampling"
|
||||
fi
|
||||
|
||||
echo "Running command: $COMMAND"
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
}
|
||||
|
||||
for run_idx in $(seq 1 $N_RUNS); do
|
||||
if [ -n "$SKIP_IDS_THRESHOLD" ]; then
|
||||
echo "Computing SKIP_IDS for run $run_idx..."
|
||||
SKIP_CMD="poetry run python evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py $SKIP_IDS_THRESHOLD"
|
||||
if [ -n "$SKIP_IDS_PATTERN" ]; then
|
||||
SKIP_CMD="$SKIP_CMD --pattern \"$SKIP_IDS_PATTERN\""
|
||||
fi
|
||||
COMPUTED_SKIP_IDS=$(eval $SKIP_CMD)
|
||||
SKIP_STATUS=$?
|
||||
if [ $SKIP_STATUS -ne 0 ]; then
|
||||
echo "ERROR: Skip IDs computation failed with exit code $SKIP_STATUS"
|
||||
exit $SKIP_STATUS
|
||||
fi
|
||||
echo "COMPUTED_SKIP_IDS: $COMPUTED_SKIP_IDS"
|
||||
else
|
||||
echo "SKIP_IDS_THRESHOLD not provided, skipping SKIP_IDS computation"
|
||||
COMPUTED_SKIP_IDS=""
|
||||
fi
|
||||
|
||||
# Concatenate COMPUTED_SKIP_IDS and INPUT_SKIP_IDS
|
||||
if [ -n "$COMPUTED_SKIP_IDS" ] && [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="${COMPUTED_SKIP_IDS},${INPUT_SKIP_IDS}"
|
||||
elif [ -n "$COMPUTED_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$COMPUTED_SKIP_IDS"
|
||||
elif [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$INPUT_SKIP_IDS"
|
||||
else
|
||||
unset SKIP_IDS
|
||||
fi
|
||||
|
||||
echo "FINAL SKIP_IDS: $SKIP_IDS"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
echo "### Running inference... ###"
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from inspect import signature
|
||||
from typing import Any, Awaitable, Callable, TextIO
|
||||
from typing import Any, Awaitable, Callable, Optional, TextIO
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel
|
||||
@@ -222,6 +222,7 @@ def prepare_dataset(
|
||||
eval_n_limit: int,
|
||||
eval_ids: list[str] | None = None,
|
||||
skip_num: int | None = None,
|
||||
filter_func: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None,
|
||||
):
|
||||
assert 'instance_id' in dataset.columns, (
|
||||
"Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column."
|
||||
@@ -265,6 +266,12 @@ def prepare_dataset(
|
||||
f'Randomly sampling {eval_n_limit} unique instances with random seed 42.'
|
||||
)
|
||||
|
||||
if filter_func is not None:
|
||||
dataset = filter_func(dataset)
|
||||
logger.info(
|
||||
f'Applied filter after sampling: {len(dataset)} instances remaining'
|
||||
)
|
||||
|
||||
def make_serializable(instance_dict: dict) -> dict:
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -188,172 +188,4 @@ describe("PaymentForm", () => {
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cancel Subscription", () => {
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
const cancelSubscriptionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"cancelSubscription",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock active subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-12-31T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render cancel subscription button when user has active subscription", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render cancel subscription button when user has no subscription", async () => {
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.queryByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show confirmation modal when cancel subscription button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
// The message should be rendered (either with Trans component or regular text)
|
||||
const modalContent = screen.getByTestId("cancel-subscription-modal");
|
||||
expect(modalContent).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close modal when cancel button in modal is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Modal should be visible
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click cancel in modal
|
||||
const modalCancelButton = screen.getByTestId("modal-cancel-button");
|
||||
await user.click(modalCancelButton);
|
||||
|
||||
// Modal should be closed
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call cancel subscription API when confirm button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Click confirm in modal
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Should call the cancel subscription API
|
||||
expect(cancelSubscriptionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should close modal after successful cancellation", async () => {
|
||||
const user = userEvent.setup();
|
||||
cancelSubscriptionSpy.mockResolvedValue({
|
||||
status: "success",
|
||||
message: "Subscription cancelled successfully",
|
||||
});
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Wait for API call to complete and modal to close
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show next billing date for active subscription", async () => {
|
||||
// Mock active subscription with end_at as next billing date
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.getByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).toBeInTheDocument();
|
||||
// Check that it contains some date-related content (translation key or actual date)
|
||||
expect(nextBillingInfo).toHaveTextContent(
|
||||
/2025|PAYMENT.*BILLING.*DATE/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show next billing date when subscription is cancelled", async () => {
|
||||
// Mock cancelled subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: "2024-06-15T10:30:00Z",
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.queryByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JupyterEditor", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the Zustand store before each test
|
||||
useJupyterStore.setState({
|
||||
cells: Array(20).fill({
|
||||
content: "Test cell content",
|
||||
type: "input",
|
||||
imageUrls: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
});
|
||||
|
||||
render(
|
||||
<div style={{ height: "100vh" }}>
|
||||
<JupyterEditor maxWidth={800} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("jupyter-container");
|
||||
expect(container).toHaveClass("flex-1 overflow-y-auto");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const renderTerminal = (commands: Command[] = []) => {
|
||||
};
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
// Terminal is now read-only - no user input functionality
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
@@ -21,8 +22,6 @@ describe.skip("Terminal", () => {
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
@@ -13,8 +14,12 @@ import {
|
||||
OptimisticUserMessageStoreComponent,
|
||||
ErrorMessageStoreComponent,
|
||||
} from "./helpers/websocket-test-components";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import {
|
||||
ConversationWebSocketProvider,
|
||||
useConversationWebSocket,
|
||||
} from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
@@ -417,7 +422,206 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
// 8. History Loading State Tests
|
||||
describe("History Loading State", () => {
|
||||
it("should track history loading state using event count from API", async () => {
|
||||
const conversationId = "test-conversation-with-history";
|
||||
|
||||
// Mock the event count API to return 3 events
|
||||
const expectedEventCount = 3;
|
||||
|
||||
// Create 3 mock events to simulate history
|
||||
const mockHistoryEvents = [
|
||||
createMockUserMessageEvent({ id: "history-event-1" }),
|
||||
createMockMessageEvent({ id: "history-event-2" }),
|
||||
createMockMessageEvent({ id: "history-event-3" }),
|
||||
];
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
<div data-testid="expected-event-count">{expectedEventCount}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty conversation history", async () => {
|
||||
const conversationId = "test-conversation-empty";
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(0);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
// No events sent for empty history
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Should quickly transition from loading to not loading when count is 0
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle history loading with large event count", async () => {
|
||||
const conversationId = "test-conversation-large-history";
|
||||
|
||||
// Create 50 mock events to simulate large history
|
||||
const expectedEventCount = 50;
|
||||
const mockHistoryEvents = Array.from({ length: 50 }, (_, i) =>
|
||||
createMockMessageEvent({ id: `history-event-${i + 1}` }),
|
||||
);
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("50");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
|
||||
@@ -38,8 +38,7 @@ export const createWebSocketTestSetup = (
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
* Uses a wildcard pattern to match any conversation ID
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
createWebSocketTestSetup("ws://localhost:3000/sockets/events/*");
|
||||
|
||||
@@ -35,13 +35,12 @@ function TestTerminalComponent() {
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
// Terminal is read-only - no longer tests user input functionality
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -25,12 +23,6 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
// Mock useIsAllHandsSaaSEnvironment hook
|
||||
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
|
||||
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
|
||||
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -54,9 +46,6 @@ beforeEach(() => {
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
|
||||
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
@@ -605,9 +594,14 @@ describe("Form submission", () => {
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
// Component automatically shows advanced view when advanced settings exist
|
||||
// Switch to basic view to test clearing advanced settings
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
// Now we should be in basic view
|
||||
await screen.findByTestId("llm-settings-form-basic");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
|
||||
@@ -731,405 +725,3 @@ describe("Status toasts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
describe("SaaS subscription", () => {
|
||||
// Common mock configurations
|
||||
const MOCK_SAAS_CONFIG = {
|
||||
APP_MODE: "saas" as const,
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_ACTIVE_SUBSCRIPTION = {
|
||||
start_at: "2024-01-01",
|
||||
end_at: "2024-12-31",
|
||||
created_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to ensure it's not called
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should have a clickable upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).not.toBeDisabled();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled or non-interactive
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, so it's not visible in basic view
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
|
||||
// Values should not change
|
||||
expect(apiKeyInput).toHaveValue("");
|
||||
|
||||
// Try to submit form - should not call API
|
||||
await userEvent.click(submitButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call subscription checkout API when upgrade button is clicked", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock the subscription checkout API call
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Click the upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
await userEvent.click(upgradeButton);
|
||||
|
||||
// Should call the subscription checkout API
|
||||
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock subscription checkout API
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
|
||||
// Mock authentication to return false (unauthenticated) from the start
|
||||
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
|
||||
|
||||
// Mock settings to return default settings even when unauthenticated
|
||||
// This is necessary because the useSettings hook is disabled when user is not authenticated
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Wait for either the settings screen or skeleton to appear
|
||||
await waitFor(() => {
|
||||
const settingsScreen = screen.queryByTestId("llm-settings-screen");
|
||||
const skeleton = screen.queryByTestId("app-settings-skeleton");
|
||||
expect(settingsScreen || skeleton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// If we get the skeleton, the test scenario isn't valid - skip the rest
|
||||
if (screen.queryByTestId("app-settings-skeleton")) {
|
||||
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
|
||||
// This is the expected behavior - unauthenticated users see a skeleton loading state
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Upgrade button should be disabled for unauthenticated users
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).toBeDisabled();
|
||||
|
||||
// Clicking disabled button should not call the API
|
||||
await userEvent.click(upgradeButton);
|
||||
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show upgrade banner
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
|
||||
// Form should NOT be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to track calls
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that basic form elements are disabled for unsubscribed users
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should NOT call save settings API for unsubscribed users
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show backdrop overlay for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should show backdrop overlay
|
||||
const backdrop = screen.getByTestId("settings-backdrop");
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show backdrop overlay for subscribed users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show backdrop overlay
|
||||
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
// Mock URL search params with ?checkout=success
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "success" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=success parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify success toast is displayed with correct message
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"SUBSCRIPTION$SUCCESS",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
// Mock URL search params with ?checkout=cancel
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "cancel" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=cancel parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify error toast is displayed with correct message
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
|
||||
});
|
||||
|
||||
it("should show upgrade banner when subscription is expired or disabled", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
|
||||
// The backend only returns active subscriptions within their validity period
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ActionMessage } from "#/types/message";
|
||||
// Mock the store and actions
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
const mockAppendJupyterInput = vi.fn();
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
@@ -21,14 +20,6 @@ vi.mock("#/state/command-store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-store", () => ({
|
||||
useJupyterStore: {
|
||||
getState: () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
@@ -63,10 +54,9 @@ describe("handleActionMessage", () => {
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
|
||||
it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
@@ -84,10 +74,7 @@ describe("handleActionMessage", () => {
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
|
||||
"print('Hello from Jupyter!')",
|
||||
);
|
||||
// Jupyter functionality has been removed, so nothing should be called
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -112,6 +99,5 @@ describe("handleActionMessage", () => {
|
||||
// Check that nothing was dispatched or called
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -187,7 +187,7 @@ class ConversationService {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const url = `${this.getConversationUrl(conversationId)}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url, {
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
@@ -10,24 +11,10 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
V1SandboxInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@@ -160,7 +147,7 @@ class V1ConversationService {
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
@@ -188,7 +175,35 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeConversation(
|
||||
conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/run`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@@ -254,6 +269,32 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 sandboxes by their IDs
|
||||
* Returns null for any missing sandboxes
|
||||
*
|
||||
* @param ids Array of sandbox IDs (max 100)
|
||||
* @returns Array of sandboxes or null for missing ones
|
||||
*/
|
||||
static async batchGetSandboxes(
|
||||
ids: string[],
|
||||
): Promise<(V1SandboxInfo | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("id", id));
|
||||
|
||||
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||
`/api/v1/sandboxes?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
@@ -277,7 +318,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Create FormData with the file
|
||||
const formData = new FormData();
|
||||
@@ -291,6 +332,37 @@ class V1ConversationService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation config (runtime_id) for a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns Object containing runtime_id
|
||||
*/
|
||||
static async getConversationConfig(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of events for a conversation
|
||||
* Uses the V1 API endpoint: GET /api/v1/events/count
|
||||
*
|
||||
* @param conversationId The conversation ID to get event count for
|
||||
* @returns The number of events in the conversation
|
||||
*/
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -98,3 +98,18 @@ export interface V1AppConversation {
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
|
||||
export interface V1ExposedUrl {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface V1SandboxInfo {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_spec_id: string;
|
||||
status: V1SandboxStatus;
|
||||
session_api_key: string | null;
|
||||
exposed_urls: V1ExposedUrl[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
41
frontend/src/api/event-service/event-service.api.ts
Normal file
41
frontend/src/api/event-service/event-service.api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
* Respond to a confirmation request in a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param request The confirmation response request
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns The confirmation response
|
||||
*/
|
||||
static async respondToConfirmation(
|
||||
conversationId: string,
|
||||
conversationUrl: string,
|
||||
request: ConfirmationResponseRequest,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<ConfirmationResponseResponse> {
|
||||
// Build the runtime URL using the conversation URL
|
||||
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
|
||||
|
||||
// Build session headers for authentication
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Make the API call to the runtime endpoint
|
||||
const { data } = await axios.post<ConfirmationResponseResponse>(
|
||||
`${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
request,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
8
frontend/src/api/event-service/event-service.types.ts
Normal file
8
frontend/src/api/event-service/event-service.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ConfirmationResponseRequest {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmationResponseResponse {
|
||||
success: boolean;
|
||||
}
|
||||
89
frontend/src/api/git-service/v1-git-service.api.ts
Normal file
89
frontend/src/api/git-service/v1-git-service.api.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import { mapV1ToV0Status } from "#/utils/git-status-mapper";
|
||||
import type {
|
||||
GitChange,
|
||||
GitChangeDiff,
|
||||
V1GitChangeStatus,
|
||||
} from "../open-hands.types";
|
||||
|
||||
interface V1GitChange {
|
||||
status: V1GitChangeStatus;
|
||||
path: string;
|
||||
}
|
||||
|
||||
class V1GitService {
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/git/changes")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git changes for a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/changes/{path}
|
||||
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @param path The git repository path (e.g., /workspace/project or /workspace/project/OpenHands)
|
||||
* @returns List of git changes with V0-compatible status types
|
||||
*/
|
||||
static async getGitChanges(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChange[]> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/changes/${encodedPath}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
|
||||
const { data } = await axios.get<V1GitChange[]>(url, { headers });
|
||||
|
||||
// Map V1 statuses to V0 format for compatibility
|
||||
return data.map((change) => ({
|
||||
status: mapV1ToV0Status(change.status),
|
||||
path: change.path,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git change diff for a specific file in a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/diff/{path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @param path The file path to get diff for
|
||||
* @returns Git change diff
|
||||
*/
|
||||
static async getGitChangeDiff(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/diff/${encodedPath}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.get<GitChangeDiff>(url, { headers });
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1GitService;
|
||||
@@ -84,8 +84,13 @@ export interface ResultSet<T> {
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use V1GitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
|
||||
*/
|
||||
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
|
||||
|
||||
export type V1GitChangeStatus = "MOVED" | "ADDED" | "DELETED" | "UPDATED";
|
||||
|
||||
export interface GitChange {
|
||||
status: GitChangeStatus;
|
||||
path: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -64,8 +65,10 @@ export function ChatInterface() {
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { isTask } = useTaskPolling();
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -94,17 +97,38 @@ export function ChatInterface() {
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Instantly scroll to bottom when history loading completes
|
||||
const prevLoadingHistoryRef = React.useRef(
|
||||
conversationWebSocket?.isLoadingHistory,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const wasLoading = prevLoadingHistoryRef.current;
|
||||
const isLoading = conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
// When history loading transitions from true to false, instantly scroll to bottom
|
||||
if (wasLoading && !isLoading && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
|
||||
prevLoadingHistoryRef.current = isLoading;
|
||||
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
@@ -202,7 +226,7 @@ export function ChatInterface() {
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
@@ -228,6 +252,14 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversationWebSocket?.isLoadingHistory &&
|
||||
isV1Conversation &&
|
||||
!isTask && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
@@ -237,13 +269,8 @@ export function ChatInterface() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
|
||||
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversation";
|
||||
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
@@ -26,6 +28,8 @@ export function ChatInputActions({
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const v1PauseConversationMutation = useV1PauseConversation();
|
||||
const v1ResumeConversationMutation = useV1ResumeConversation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
@@ -38,7 +42,8 @@ export function ChatInputActions({
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Empty function for now
|
||||
// V1: Pause the conversation (agent execution)
|
||||
v1PauseConversationMutation.mutate({ conversationId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,11 +51,24 @@ export function ChatInputActions({
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const handleResumeAgentClick = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Resume the conversation (agent execution)
|
||||
v1ResumeConversationMutation.mutate({ conversationId });
|
||||
return;
|
||||
}
|
||||
|
||||
// V0: Call the original handleResumeAgent (sends "continue" message)
|
||||
handleResumeAgent();
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing = pauseConversationSandboxMutation.isPending;
|
||||
const isPausing =
|
||||
pauseConversationSandboxMutation.isPending ||
|
||||
v1PauseConversationMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
@@ -66,7 +84,7 @@ export function ChatInputActions({
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={handlePauseAgent}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
handleResumeAgent={handleResumeAgentClick}
|
||||
disabled={disabled}
|
||||
isPausing={isPausing}
|
||||
/>
|
||||
|
||||
@@ -10,19 +10,22 @@ interface GitControlBarPrButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
hasRepository: boolean;
|
||||
currentGitProvider: Provider;
|
||||
isConversationReady?: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarPrButton({
|
||||
onSuggestionsClick,
|
||||
hasRepository,
|
||||
currentGitProvider,
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isButtonEnabled = providersAreSet && hasRepository;
|
||||
const isButtonEnabled =
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePrClick = () => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
|
||||
@@ -8,10 +8,12 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitControlBarPullButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
isConversationReady?: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarPullButton({
|
||||
onSuggestionsClick,
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPullButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -20,7 +22,8 @@ export function GitControlBarPullButton({
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const hasRepository = conversation?.selected_repository;
|
||||
const isButtonEnabled = providersAreSet && hasRepository;
|
||||
const isButtonEnabled =
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePullClick = () => {
|
||||
posthog.capture("pull_button_clicked");
|
||||
|
||||
@@ -10,19 +10,22 @@ interface GitControlBarPushButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
hasRepository: boolean;
|
||||
currentGitProvider: Provider;
|
||||
isConversationReady?: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarPushButton({
|
||||
onSuggestionsClick,
|
||||
hasRepository,
|
||||
currentGitProvider,
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPushButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isButtonEnabled = providersAreSet && hasRepository;
|
||||
const isButtonEnabled =
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePushClick = () => {
|
||||
posthog.capture("push_button_clicked");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GitControlBarPushButton } from "./git-control-bar-push-button";
|
||||
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
|
||||
@@ -19,6 +20,7 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { repositoryInfo } = useTaskPolling();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
|
||||
// Priority: conversation data > task data
|
||||
// This ensures we show repository info immediately from task, then transition to conversation data
|
||||
@@ -31,6 +33,9 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
|
||||
const hasRepository = !!selectedRepository;
|
||||
|
||||
// Enable buttons only when conversation exists and WS is connected
|
||||
const isConversationReady = !!conversation && webSocketStatus === "CONNECTED";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row gap-2.5 items-center overflow-x-auto flex-wrap md:flex-nowrap relative scrollbar-hide">
|
||||
@@ -66,6 +71,7 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
>
|
||||
<GitControlBarPullButton
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
isConversationReady={isConversationReady}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
@@ -78,6 +84,7 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
hasRepository={hasRepository}
|
||||
currentGitProvider={gitProvider}
|
||||
isConversationReady={isConversationReady}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
@@ -90,6 +97,7 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
hasRepository={hasRepository}
|
||||
currentGitProvider={gitProvider}
|
||||
isConversationReady={isConversationReady}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
</>
|
||||
|
||||
@@ -74,19 +74,24 @@ export function AgentStatus({
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#525252] box-border content-stretch flex flex-row gap-[3px] items-center justify-center overflow-clip px-0.5 py-1 relative rounded-[100px] shrink-0 size-6 transition-all duration-200 active:scale-95",
|
||||
(shouldShownAgentStop || shouldShownAgentResume) &&
|
||||
!shouldShownAgentLoading &&
|
||||
(shouldShownAgentStop || shouldShownAgentResume) &&
|
||||
"hover:bg-[#737373] cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{shouldShownAgentLoading && <AgentLoading />}
|
||||
{shouldShownAgentStop && <ChatStopButton handleStop={handleStop} />}
|
||||
{shouldShownAgentResume && (
|
||||
{!shouldShownAgentLoading && shouldShownAgentStop && (
|
||||
<ChatStopButton handleStop={handleStop} />
|
||||
)}
|
||||
{!shouldShownAgentLoading && shouldShownAgentResume && (
|
||||
<ChatResumeAgentButton
|
||||
onAgentResumed={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{shouldShownAgentError && <CircleErrorIcon className="w-4 h-4" />}
|
||||
{!shouldShownAgentLoading && shouldShownAgentError && (
|
||||
<CircleErrorIcon className="w-4 h-4" />
|
||||
)}
|
||||
{!shouldShownAgentLoading &&
|
||||
!shouldShownAgentStop &&
|
||||
!shouldShownAgentResume &&
|
||||
|
||||
@@ -8,23 +8,23 @@ import { TabContentArea } from "./tab-content-area";
|
||||
import { ConversationTabTitle } from "../conversation-tab-title";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine which tab is active based on the current path
|
||||
const isEditorActive = selectedTab === "editor";
|
||||
const isBrowserActive = selectedTab === "browser";
|
||||
const isJupyterActive = selectedTab === "jupyter";
|
||||
const isServedActive = selectedTab === "served";
|
||||
const isVSCodeActive = selectedTab === "vscode";
|
||||
const isTerminalActive = selectedTab === "terminal";
|
||||
@@ -37,11 +37,6 @@ export function ConversationTabContent() {
|
||||
component: BrowserTab,
|
||||
isActive: isBrowserActive,
|
||||
},
|
||||
{
|
||||
key: "jupyter",
|
||||
component: JupyterTab,
|
||||
isActive: isJupyterActive,
|
||||
},
|
||||
{ key: "served", component: ServedTab, isActive: isServedActive },
|
||||
{ key: "vscode", component: VSCodeTab, isActive: isVSCodeActive },
|
||||
{
|
||||
@@ -58,9 +53,6 @@ export function ConversationTabContent() {
|
||||
if (isBrowserActive) {
|
||||
return t(I18nKey.COMMON$BROWSER);
|
||||
}
|
||||
if (isJupyterActive) {
|
||||
return t(I18nKey.COMMON$JUPYTER);
|
||||
}
|
||||
if (isServedActive) {
|
||||
return t(I18nKey.COMMON$APP);
|
||||
}
|
||||
@@ -74,7 +66,6 @@ export function ConversationTabContent() {
|
||||
}, [
|
||||
isEditorActive,
|
||||
isBrowserActive,
|
||||
isJupyterActive,
|
||||
isServedActive,
|
||||
isVSCodeActive,
|
||||
isTerminalActive,
|
||||
@@ -89,7 +80,11 @@ export function ConversationTabContent() {
|
||||
<ConversationTabTitle title={conversationTabTitle} />
|
||||
<TabContentArea>
|
||||
{tabs.map(({ key, component: Component, isActive }) => (
|
||||
<TabWrapper key={key} isActive={isActive}>
|
||||
<TabWrapper
|
||||
// Force Terminal tab remount to reset XTerm buffer/state when conversationId changes
|
||||
key={key === "terminal" ? `${key}-${conversationId}` : key}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Component />
|
||||
</TabWrapper>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
@@ -108,13 +107,6 @@ export function ConversationTabs() {
|
||||
tooltipContent: t(I18nKey.COMMON$TERMINAL),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("jupyter"),
|
||||
icon: JupyterIcon,
|
||||
onClick: () => onTabSelected("jupyter"),
|
||||
tooltipContent: t(I18nKey.COMMON$JUPYTER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$JUPYTER),
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GitChangeStatus } from "#/api/open-hands.types";
|
||||
import { getLanguageFromPath } from "#/utils/get-language-from-path";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ChevronUp from "#/icons/chveron-up.svg?react";
|
||||
import { useGitDiff } from "#/hooks/query/use-get-diff";
|
||||
import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
@@ -64,7 +64,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isRefetching,
|
||||
} = useGitDiff({
|
||||
} = useUnifiedGitDiff({
|
||||
filePath,
|
||||
type,
|
||||
enabled: !isCollapsed,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
|
||||
interface JupytrerCellInputProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function JupytrerCellInput({ code }: JupytrerCellInputProps) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">EXECUTE</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark} wrapLongLines>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Markdown from "react-markdown";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { JupyterLine } from "#/utils/parse-cell-content";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
|
||||
interface JupyterCellOutputProps {
|
||||
lines: JupyterLine[];
|
||||
}
|
||||
|
||||
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">
|
||||
{t(I18nKey.JUPYTER$OUTPUT_LABEL)}
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{/* display the lines as plaintext or image */}
|
||||
{lines.map((line, index) => {
|
||||
if (line.type === "image") {
|
||||
// Use markdown to display the image
|
||||
const imageMarkdown = line.url
|
||||
? ``
|
||||
: line.content;
|
||||
return (
|
||||
<div key={index}>
|
||||
<Markdown
|
||||
components={{
|
||||
p: paragraph,
|
||||
}}
|
||||
urlTransform={(value: string) => value}
|
||||
>
|
||||
{imageMarkdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={index}>
|
||||
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
import { Cell } from "#/state/jupyter-store";
|
||||
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
|
||||
import { JupytrerCellInput } from "./jupyter-cell-input";
|
||||
import { JupyterCellOutput } from "./jupyter-cell-output";
|
||||
|
||||
interface JupyterCellProps {
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export function JupyterCell({ cell }: JupyterCellProps) {
|
||||
const [lines, setLines] = React.useState<JupyterLine[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLines(parseCellContent(cell.content, cell.imageUrls));
|
||||
}, [cell.content, cell.imageUrls]);
|
||||
|
||||
if (cell.type === "input") {
|
||||
return <JupytrerCellInput code={cell.content} />;
|
||||
}
|
||||
|
||||
return <JupyterCellOutput lines={lines} />;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { JupyterCell } from "./jupyter-cell";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
const jupyterRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRuntimeInactive && <WaitingForRuntimeMessage />}
|
||||
{!isRuntimeInactive && cells.length > 0 && (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll custom-scrollbar-always rounded-xl"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isRuntimeInactive && cells.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<JupyterLargeIcon width={113} height={113} color="#A1A1A1" />
|
||||
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.COMMON$JUPYTER_EMPTY_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,5 +7,5 @@ export function paragraph({
|
||||
}: React.ClassAttributes<HTMLParagraphElement> &
|
||||
React.HTMLAttributes<HTMLParagraphElement> &
|
||||
ExtraProps) {
|
||||
return <p className="pb-[10px] last:pb-0">{children}</p>;
|
||||
return <p className="py-2.5 first:pt-0 last:pb-0">{children}</p>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
@@ -11,24 +10,13 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
import { CancelSubscriptionModal } from "./cancel-subscription-modal";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
const [showCancelModal, setShowCancelModal] = React.useState(false);
|
||||
|
||||
const subscriptionExpiredDate =
|
||||
subscriptionAccess?.end_at &&
|
||||
new Date(subscriptionAccess.end_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
@@ -94,50 +82,7 @@ export function PaymentForm() {
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
<PoweredByStripeTag />
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Button or Cancellation Message */}
|
||||
{subscriptionAccess && (
|
||||
<div className="flex flex-col w-[680px] gap-2 mt-4">
|
||||
{subscriptionAccess.cancelled_at ? (
|
||||
<div className="text-red-500 text-sm">
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-subscription-button"
|
||||
variant="ghost-danger"
|
||||
type="button"
|
||||
onClick={() => setShowCancelModal(true)}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION)}
|
||||
</BrandButton>
|
||||
<div
|
||||
className="text-sm text-gray-300"
|
||||
data-testid="next-billing-date"
|
||||
>
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$NEXT_BILLING_DATE}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Modal */}
|
||||
<CancelSubscriptionModal
|
||||
isOpen={showCancelModal}
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
endDate={subscriptionExpiredDate}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ interface NavigationItem {
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsLayout({
|
||||
children,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsLayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -44,7 +42,6 @@ export function SettingsLayout({
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={closeMobileMenu}
|
||||
navigationItems={navigationItems}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import SettingsIcon from "#/icons/settings-gear.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { ProPill } from "./pro-pill";
|
||||
|
||||
interface NavigationItem {
|
||||
to: string;
|
||||
@@ -17,14 +16,12 @@ interface SettingsNavigationProps {
|
||||
isMobileMenuOpen: boolean;
|
||||
onCloseMobileMenu: () => void;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsNavigation({
|
||||
isMobileMenuOpen,
|
||||
onCloseMobileMenu,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -85,7 +82,6 @@ export function SettingsNavigation({
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
{isSaas && to === "/settings" && <ProPill />}
|
||||
</div>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { RiskAlert } from "#/components/shared/risk-alert";
|
||||
import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV1Event, isActionEvent } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
|
||||
export function V1ConfirmationButtons() {
|
||||
const v1SubmittedEventIds = useEventMessageStore(
|
||||
(state) => state.v1SubmittedEventIds,
|
||||
);
|
||||
const addV1SubmittedEventId = useEventMessageStore(
|
||||
(state) => state.addV1SubmittedEventId,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { mutate: respondToConfirmation } = useRespondToConfirmation();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent V1 action awaiting confirmation
|
||||
const awaitingAction = events
|
||||
.filter(isV1Event)
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((ev) => {
|
||||
if (ev.source !== "agent") return false;
|
||||
// For V1, we check if the agent state is waiting for confirmation
|
||||
return curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
});
|
||||
|
||||
const handleConfirmation = useCallback(
|
||||
(accept: boolean) => {
|
||||
if (!awaitingAction || !conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as submitted to prevent duplicate submissions
|
||||
addV1SubmittedEventId(awaitingAction.id);
|
||||
|
||||
// Call the V1 API endpoint
|
||||
respondToConfirmation({
|
||||
conversationId: conversation.conversation_id,
|
||||
conversationUrl: conversation.url || "",
|
||||
sessionApiKey: conversation.session_api_key,
|
||||
accept,
|
||||
});
|
||||
},
|
||||
[
|
||||
awaitingAction,
|
||||
conversation,
|
||||
addV1SubmittedEventId,
|
||||
respondToConfirmation,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!awaitingAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleCancelShortcut = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueShortcut = (event: KeyboardEvent) => {
|
||||
if (event.metaKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
|
||||
handleCancelShortcut(event);
|
||||
// Continue: Cmd+Enter (⌘↩)
|
||||
handleContinueShortcut(event);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [awaitingAction, handleConfirmation]);
|
||||
|
||||
// Only show if agent is waiting for confirmation and we haven't already submitted
|
||||
if (
|
||||
curAgentState !== AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
!awaitingAction ||
|
||||
v1SubmittedEventIds.includes(awaitingAction.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get security risk from the action (only ActionEvent has security_risk)
|
||||
const risk = isActionEvent(awaitingAction)
|
||||
? awaitingAction.security_risk
|
||||
: SecurityRisk.UNKNOWN;
|
||||
|
||||
const isHighRisk = risk === SecurityRisk.HIGH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{isHighRisk && (
|
||||
<RiskAlert
|
||||
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
|
||||
icon={<WarningIcon width={16} height={16} color="#fff" />}
|
||||
severity="high"
|
||||
title={t(I18nKey.COMMON$HIGH_RISK)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm font-normal text-white">
|
||||
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionTooltip
|
||||
type="reject"
|
||||
onClick={() => handleConfirmation(false)}
|
||||
/>
|
||||
<ActionTooltip
|
||||
type="confirm"
|
||||
onClick={() => handleConfirmation(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,9 +134,16 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
case "TaskTrackerObservation": {
|
||||
const { command } = event.observation;
|
||||
if (command === "plan") {
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
|
||||
} else {
|
||||
// command === "view"
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -19,6 +19,10 @@ const getFileEditorObservationContent = (
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
if (observation.error) {
|
||||
return `**Error:**\n${observation.error}`;
|
||||
}
|
||||
|
||||
const successMessage = getObservationResult(event) === "success";
|
||||
|
||||
// For view commands or successful edits with content changes, format as code block
|
||||
|
||||
@@ -11,9 +11,10 @@ export const getObservationResult = (
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation": {
|
||||
const exitCode = observation.exit_code;
|
||||
const { metadata } = observation;
|
||||
|
||||
if (exitCode === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0) return "success"; // Command executed successfully
|
||||
if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully
|
||||
return "error"; // Command failed
|
||||
}
|
||||
case "FileEditorObservation":
|
||||
|
||||
@@ -7,17 +7,6 @@ import {
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
// V1 events that should not be rendered
|
||||
const NO_RENDER_ACTION_TYPES = [
|
||||
"ThinkAction",
|
||||
// Add more action types that should not be rendered
|
||||
];
|
||||
|
||||
const NO_RENDER_OBSERVATION_TYPES = [
|
||||
"ThinkObservation",
|
||||
// Add more observation types that should not be rendered
|
||||
];
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
// Explicitly exclude system events that should not be rendered in chat
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
@@ -34,18 +23,12 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !NO_RENDER_ACTION_TYPES.includes(actionType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render observation events (with filtering)
|
||||
// Render observation events
|
||||
if (isObservationEvent(event)) {
|
||||
// For V1, observation is an object with kind property
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
// Note: ObservationEvent source is always "environment", not "user"
|
||||
// So no need to check for user source here
|
||||
|
||||
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render message events (user and assistant messages)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
isLastMessage,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
@@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
|
||||
interface ThoughtEventMessageProps {
|
||||
event: ActionEvent;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ThoughtEventMessage({
|
||||
event,
|
||||
actions,
|
||||
}: ThoughtEventMessageProps) {
|
||||
// Extract thought content from the action event
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
// If there's no thought content, don't render anything
|
||||
if (!thoughtContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
@@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
@@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps {
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function UserAssistantEventMessage({
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
messages: OpenHandsEvent[];
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@@ -37,8 +36,7 @@ interface EventMessageProps {
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
messages,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@@ -46,9 +44,6 @@ export function EventMessage({
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
@@ -74,19 +69,6 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
@@ -97,23 +79,53 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Action events - render thought + action (will be replaced by thought + observation)
|
||||
if (isActionEvent(event)) {
|
||||
return (
|
||||
<>
|
||||
<ThoughtEventMessage event={event} actions={actions} />
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Observation events - find the corresponding action and render thought + observation
|
||||
if (isObservationEvent(event)) {
|
||||
// Find the action that this observation is responding to
|
||||
const correspondingAction = messages.find(
|
||||
(msg) => isActionEvent(msg) && msg.id === event.action_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{correspondingAction && isActionEvent(correspondingAction) && (
|
||||
<ThoughtEventMessage event={correspondingAction} actions={actions} />
|
||||
)}
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
// Generic fallback for all other events
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
@@ -9,30 +8,16 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
|
||||
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
({ messages, allEvents }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
@@ -42,8 +27,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
messages={allEvents}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState =
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
isLoadingHistory: boolean;
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
@@ -67,6 +70,13 @@ export function ConversationWebSocketProvider({
|
||||
const { setAgentStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
|
||||
// History loading state
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
const [expectedEventCount, setExpectedEventCount] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const receivedEventCountRef = useRef(0);
|
||||
|
||||
// Build WebSocket URL from props
|
||||
// Only build URL if we have both conversationId and conversationUrl
|
||||
// This prevents connection attempts during task polling phase
|
||||
@@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({
|
||||
return buildWebSocketUrl(conversationId, conversationUrl);
|
||||
}, [conversationId, conversationUrl]);
|
||||
|
||||
// Reset hasConnected flag when conversation changes
|
||||
// Reset hasConnected flag and history loading state when conversation changes
|
||||
useEffect(() => {
|
||||
hasConnectedRef.current = false;
|
||||
setIsLoadingHistory(true);
|
||||
setExpectedEventCount(null);
|
||||
receivedEventCountRef.current = 0;
|
||||
}, [conversationId]);
|
||||
|
||||
// Check if we've received all events when expectedEventCount becomes available
|
||||
useEffect(() => {
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount &&
|
||||
isLoadingHistory
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [expectedEventCount, isLoadingHistory]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
|
||||
// Track received events for history loading (count ALL events from WebSocket)
|
||||
// Always count when loading, even if we don't have the expected count yet
|
||||
if (isLoadingHistory) {
|
||||
receivedEventCountRef.current += 1;
|
||||
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
@@ -141,6 +178,8 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
isLoadingHistory,
|
||||
expectedEventCount,
|
||||
setErrorMessage,
|
||||
removeOptimisticUserMessage,
|
||||
queryClient,
|
||||
@@ -164,10 +203,27 @@ export function ConversationWebSocketProvider({
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
onOpen: () => {
|
||||
onOpen: async () => {
|
||||
setConnectionState("OPEN");
|
||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
|
||||
// Fetch expected event count for history loading detection
|
||||
if (conversationId) {
|
||||
try {
|
||||
const count =
|
||||
await V1ConversationService.getEventCount(conversationId);
|
||||
setExpectedEventCount(count);
|
||||
|
||||
// If no events expected, mark as loaded immediately
|
||||
if (count === 0) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to marking as loaded to avoid infinite loading state
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
@@ -188,7 +244,13 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
};
|
||||
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
|
||||
}, [
|
||||
handleMessage,
|
||||
setErrorMessage,
|
||||
removeErrorMessage,
|
||||
sessionApiKey,
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
// Only attempt WebSocket connection when we have a valid URL
|
||||
// This prevents connection attempts during task polling phase
|
||||
@@ -246,8 +308,8 @@ export function ConversationWebSocketProvider({
|
||||
}, [socket, wsUrl]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ connectionState, sendMessage }),
|
||||
[connectionState, sendMessage],
|
||||
() => ({ connectionState, sendMessage, isLoadingHistory }),
|
||||
[connectionState, sendMessage, isLoadingHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,11 +18,15 @@ export const getConversationVersionFromQueryCache = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a V1 conversation's sandbox_id
|
||||
* Fetches a V1 conversation's sandbox_id and conversation_url
|
||||
*/
|
||||
const fetchV1ConversationSandboxId = async (
|
||||
const fetchV1ConversationData = async (
|
||||
conversationId: string,
|
||||
): Promise<string> => {
|
||||
): Promise<{
|
||||
sandboxId: string;
|
||||
conversationUrl: string | null;
|
||||
sessionApiKey: string | null;
|
||||
}> => {
|
||||
const conversations = await V1ConversationService.batchGetAppConversations([
|
||||
conversationId,
|
||||
]);
|
||||
@@ -32,17 +36,34 @@ const fetchV1ConversationSandboxId = async (
|
||||
throw new Error(`V1 conversation not found: ${conversationId}`);
|
||||
}
|
||||
|
||||
return appConversation.sandbox_id;
|
||||
return {
|
||||
sandboxId: appConversation.sandbox_id,
|
||||
conversationUrl: appConversation.conversation_url,
|
||||
sessionApiKey: appConversation.session_api_key,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation sandbox by fetching the sandbox_id and pausing it
|
||||
*/
|
||||
export const pauseV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.pauseSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation by fetching the conversation data and pausing it
|
||||
*/
|
||||
export const pauseV1Conversation = async (conversationId: string) => {
|
||||
const { conversationUrl, sessionApiKey } =
|
||||
await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.pauseConversation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops a V0 conversation using the legacy API
|
||||
*/
|
||||
@@ -53,10 +74,23 @@ export const stopV0Conversation = async (conversationId: string) =>
|
||||
* Resumes a V1 conversation sandbox by fetching the sandbox_id and resuming it
|
||||
*/
|
||||
export const resumeV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.resumeSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resume a V1 conversation by fetching the conversation data and resuming it
|
||||
*/
|
||||
export const resumeV1Conversation = async (conversationId: string) => {
|
||||
const { conversationUrl, sessionApiKey } =
|
||||
await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.resumeConversation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a V0 conversation using the legacy API
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useCreateConversation = () => {
|
||||
createMicroagent,
|
||||
} = variables;
|
||||
|
||||
const useV1 = USE_V1_CONVERSATION_API();
|
||||
const useV1 = USE_V1_CONVERSATION_API() && !createMicroagent;
|
||||
|
||||
if (useV1) {
|
||||
// Use V1 API - creates a conversation start task
|
||||
|
||||
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
32
frontend/src/hooks/mutation/use-respond-to-confirmation.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types";
|
||||
|
||||
interface UseRespondToConfirmationVariables {
|
||||
conversationId: string;
|
||||
conversationUrl: string;
|
||||
sessionApiKey?: string | null;
|
||||
accept: boolean;
|
||||
}
|
||||
|
||||
export const useRespondToConfirmation = () =>
|
||||
useMutation({
|
||||
mutationKey: ["respond-to-confirmation"],
|
||||
mutationFn: async ({
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
accept,
|
||||
}: UseRespondToConfirmationVariables) => {
|
||||
const request: ConfirmationResponseRequest = {
|
||||
accept,
|
||||
};
|
||||
|
||||
return EventService.respondToConfirmation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
request,
|
||||
sessionApiKey,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -26,6 +26,13 @@ export const useUpdateConversation = () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Also optimistically update the active conversation query
|
||||
queryClient.setQueryData(
|
||||
["user", "conversation", variables.conversationId],
|
||||
(old: { title: string } | undefined) =>
|
||||
old ? { ...old, title: variables.newTitle } : old,
|
||||
);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
|
||||
40
frontend/src/hooks/mutation/use-v1-pause-conversation.ts
Normal file
40
frontend/src/hooks/mutation/use-v1-pause-conversation.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { pauseV1Conversation } from "./conversation-mutation-utils";
|
||||
|
||||
export const useV1PauseConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { conversationId: string }) =>
|
||||
pauseV1Conversation(variables.conversationId),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
// Invalidate the specific conversation query to trigger automatic refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", variables.conversationId],
|
||||
});
|
||||
// Also invalidate the conversations list for consistency
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Invalidate V1 batch get queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
40
frontend/src/hooks/mutation/use-v1-resume-conversation.ts
Normal file
40
frontend/src/hooks/mutation/use-v1-resume-conversation.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { resumeV1Conversation } from "./conversation-mutation-utils";
|
||||
|
||||
export const useV1ResumeConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { conversationId: string }) =>
|
||||
resumeV1Conversation(variables.conversationId),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
// Invalidate the specific conversation query to trigger automatic refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", variables.conversationId],
|
||||
});
|
||||
// Also invalidate the conversations list for consistency
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Invalidate V1 batch get queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
11
frontend/src/hooks/query/use-batch-app-conversations.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchAppConversations = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["v1-batch-get-app-conversations", ids],
|
||||
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
11
frontend/src/hooks/query/use-batch-sandboxes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchSandboxes = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["sandboxes", "batch", ids],
|
||||
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useRuntimeIsReady } from "../use-runtime-is-ready";
|
||||
import { useActiveConversation } from "./use-active-conversation";
|
||||
|
||||
export const useConversationConfig = () => {
|
||||
/**
|
||||
* @deprecated This hook is for V0 conversations only. Use useUnifiedConversationConfig instead,
|
||||
* or useV1ConversationConfig once we fully migrate to V1.
|
||||
*/
|
||||
export const useV0ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId],
|
||||
queryKey: ["v0_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
@@ -34,3 +40,80 @@ export const useConversationConfig = () => {
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useV1ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["v1_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified hook that switches between V0 and V1 conversation config endpoints based on conversation version.
|
||||
*
|
||||
* @temporary This hook is temporary during the V0 to V1 migration period.
|
||||
* Once we fully migrate to V1, all code should use useV1ConversationConfig directly.
|
||||
*/
|
||||
export const useUnifiedConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId, isV1Conversation],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
if (isV1Conversation) {
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
}
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId && conversation !== undefined,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Keep the old export name for backward compatibility (uses unified approach)
|
||||
export const useConversationConfig = useUnifiedConversationConfig;
|
||||
|
||||
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
99
frontend/src/hooks/query/use-unified-active-host.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
import { useConversationConfig } from "./use-conversation-config";
|
||||
|
||||
/**
|
||||
* Unified hook to get active web host for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getWebHosts API endpoint and polls them
|
||||
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
|
||||
*/
|
||||
export const useUnifiedActiveHost = () => {
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: conversationConfig, isLoading: isLoadingConfig } =
|
||||
useConversationConfig();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const sandboxId = conversationConfig?.runtime_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
// Get worker URLs from V1 sandbox or legacy web hosts from V0
|
||||
const { data, isLoading: hostsQueryLoading } = useQuery({
|
||||
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
|
||||
queryFn: async () => {
|
||||
// V1: Get worker URLs from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return { hosts: [] };
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const workerUrls =
|
||||
sandbox.exposed_urls
|
||||
?.filter((url) => url.name.startsWith("WORKER_"))
|
||||
.map((url) => url.url) || [];
|
||||
|
||||
return { hosts: workerUrls };
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const hosts = await ConversationService.getWebHosts(conversationId);
|
||||
return { hosts };
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
initialData: { hosts: [] },
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Poll all hosts to find which one is active
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "unified", "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
|
||||
: hostsQueryLoading;
|
||||
|
||||
return { activeHost, isLoading };
|
||||
};
|
||||
107
frontend/src/hooks/query/use-unified-get-git-changes.ts
Normal file
107
frontend/src/hooks/query/use-unified-get-git-changes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChange } from "#/api/open-hands.types";
|
||||
|
||||
/**
|
||||
* Unified hook to get git changes for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy GitService.getGitChanges API endpoint
|
||||
* - V1: Uses the V1GitService.getGitChanges API endpoint with runtime URL
|
||||
*/
|
||||
export const useUnifiedGetGitChanges = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
|
||||
const previousDataRef = React.useRef<GitChange[] | null>(null);
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const conversationUrl = conversation?.url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// Calculate git path based on selected repository
|
||||
const gitPath = React.useMemo(
|
||||
() => getGitPath(selectedRepository),
|
||||
[selectedRepository],
|
||||
);
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: [
|
||||
"file_changes",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
conversationUrl,
|
||||
gitPath,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Use the V1 API endpoint with runtime URL
|
||||
if (isV1Conversation) {
|
||||
return V1GitService.getGitChanges(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
gitPath,
|
||||
);
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
return GitService.getGitChanges(conversationId);
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Latest changes should be on top
|
||||
React.useEffect(() => {
|
||||
if (!result.isFetching && result.isSuccess && result.data) {
|
||||
const currentData = result.data;
|
||||
|
||||
// If this is new data (not the same reference as before)
|
||||
if (currentData !== previousDataRef.current) {
|
||||
previousDataRef.current = currentData;
|
||||
|
||||
// Figure out new items by comparing with what we already have
|
||||
if (Array.isArray(currentData)) {
|
||||
const currentIds = new Set(currentData.map((item) => item.path));
|
||||
const existingIds = new Set(orderedChanges.map((item) => item.path));
|
||||
|
||||
// Filter out items that already exist in orderedChanges
|
||||
const newItems = currentData.filter(
|
||||
(item) => !existingIds.has(item.path),
|
||||
);
|
||||
|
||||
// Filter out items that no longer exist in the API response
|
||||
const existingItems = orderedChanges.filter((item) =>
|
||||
currentIds.has(item.path),
|
||||
);
|
||||
|
||||
// Add new items to the beginning
|
||||
setOrderedChanges([...newItems, ...existingItems]);
|
||||
} else {
|
||||
// If not an array, just use the data directly
|
||||
setOrderedChanges([currentData]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [result.isFetching, result.isSuccess, result.data]);
|
||||
|
||||
return {
|
||||
data: orderedChanges,
|
||||
isLoading: result.isLoading,
|
||||
isSuccess: result.isSuccess,
|
||||
isError: result.isError,
|
||||
error: result.error,
|
||||
};
|
||||
};
|
||||
67
frontend/src/hooks/query/use-unified-git-diff.ts
Normal file
67
frontend/src/hooks/query/use-unified-git-diff.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChangeStatus } from "#/api/open-hands.types";
|
||||
|
||||
type UseUnifiedGitDiffConfig = {
|
||||
filePath: string;
|
||||
type: GitChangeStatus;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified hook to get git diff for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy GitService.getGitChangeDiff API endpoint
|
||||
* - V1: Uses the V1GitService.getGitChangeDiff API endpoint with runtime URL
|
||||
*/
|
||||
export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const conversationUrl = conversation?.url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// For V1, we need to convert the relative file path to an absolute path
|
||||
// The diff endpoint expects: /workspace/project/RepoName/relative/path
|
||||
const absoluteFilePath = React.useMemo(() => {
|
||||
if (!isV1Conversation) return config.filePath;
|
||||
|
||||
const gitPath = getGitPath(selectedRepository);
|
||||
return `${gitPath}/${config.filePath}`;
|
||||
}, [isV1Conversation, selectedRepository, config.filePath]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"file_diff",
|
||||
conversationId,
|
||||
config.filePath,
|
||||
config.type,
|
||||
isV1Conversation,
|
||||
conversationUrl,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Use the V1 API endpoint with runtime URL and absolute path
|
||||
if (isV1Conversation) {
|
||||
return V1GitService.getGitChangeDiff(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
absoluteFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint with relative path
|
||||
return GitService.getGitChangeDiff(conversationId, config.filePath);
|
||||
},
|
||||
enabled: config.enabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
122
frontend/src/hooks/query/use-unified-vscode-url.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useBatchAppConversations } from "./use-batch-app-conversations";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
|
||||
interface VSCodeUrlResult {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getVSCodeUrl API endpoint
|
||||
* - V1: Gets the VSCode URL from sandbox exposed_urls
|
||||
*/
|
||||
export const useUnifiedVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Fetch V1 app conversation to get sandbox_id
|
||||
const appConversationsQuery = useBatchAppConversations(
|
||||
isV1Conversation && conversationId ? [conversationId] : [],
|
||||
);
|
||||
const appConversation = appConversationsQuery.data?.[0];
|
||||
const sandboxId = appConversation?.sandbox_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
const mainQuery = useQuery<VSCodeUrlResult>({
|
||||
queryKey: [
|
||||
"unified",
|
||||
"vscode_url",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
sandboxId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Get VSCode URL from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const vscodeUrl = sandbox.exposed_urls?.find(
|
||||
(url) => url.name === "VSCODE",
|
||||
);
|
||||
|
||||
if (!vscodeUrl) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: transformVSCodeUrl(vscodeUrl.url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
if (data.vscode_url) {
|
||||
return {
|
||||
url: transformVSCodeUrl(data.vscode_url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
refetchOnMount: true,
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? appConversationsQuery.isLoading ||
|
||||
sandboxesQuery.isLoading ||
|
||||
mainQuery.isLoading
|
||||
: mainQuery.isLoading;
|
||||
|
||||
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
|
||||
return {
|
||||
data: mainQuery.data,
|
||||
error: mainQuery.error,
|
||||
isLoading,
|
||||
isError: mainQuery.isError,
|
||||
isSuccess: mainQuery.isSuccess,
|
||||
status: mainQuery.status,
|
||||
refetch: mainQuery.refetch,
|
||||
};
|
||||
};
|
||||
@@ -2,11 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import React from "react";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { getTerminalCommand } from "#/services/terminal-service";
|
||||
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
/*
|
||||
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
|
||||
@@ -26,9 +22,11 @@ const renderCommand = (
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.writeln(
|
||||
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
|
||||
);
|
||||
const trimmedContent = content.replaceAll("\n", "\r\n").trim();
|
||||
// Only write if there's actual content to avoid empty newlines
|
||||
if (trimmedContent) {
|
||||
terminal.writeln(parseTerminalOutput(trimmedContent));
|
||||
}
|
||||
};
|
||||
|
||||
// Create a persistent reference that survives component unmounts
|
||||
@@ -36,15 +34,11 @@ const renderCommand = (
|
||||
const persistentLastCommandIndex = { current: 0 };
|
||||
|
||||
export const useTerminal = () => {
|
||||
const { send } = useSendMessage();
|
||||
const { curAgentState } = useAgentState();
|
||||
const commands = useCommandStore((state) => state.commands);
|
||||
const terminal = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
|
||||
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
|
||||
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const createTerminal = () =>
|
||||
new Terminal({
|
||||
@@ -55,6 +49,7 @@ export const useTerminal = () => {
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
allowTransparency: true,
|
||||
disableStdin: true, // Make terminal read-only
|
||||
theme: {
|
||||
background: "transparent",
|
||||
},
|
||||
@@ -63,55 +58,12 @@ export const useTerminal = () => {
|
||||
const initializeTerminal = () => {
|
||||
if (terminal.current) {
|
||||
if (fitAddon.current) terminal.current.loadAddon(fitAddon.current);
|
||||
if (ref.current) terminal.current.open(ref.current);
|
||||
}
|
||||
};
|
||||
|
||||
const copySelection = (selection: string) => {
|
||||
const clipboardItem = new ClipboardItem({
|
||||
"text/plain": new Blob([selection], { type: "text/plain" }),
|
||||
});
|
||||
|
||||
navigator.clipboard.write([clipboardItem]);
|
||||
};
|
||||
|
||||
const pasteSelection = (callback: (text: string) => void) => {
|
||||
navigator.clipboard.readText().then(callback);
|
||||
};
|
||||
|
||||
const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => {
|
||||
const isControlOrMetaPressed =
|
||||
event.type === "keydown" && (event.ctrlKey || event.metaKey);
|
||||
|
||||
if (isControlOrMetaPressed) {
|
||||
if (event.code === "KeyV") {
|
||||
pasteSelection((text: string) => {
|
||||
terminal.current?.write(text);
|
||||
cb(text);
|
||||
});
|
||||
}
|
||||
|
||||
if (event.code === "KeyC") {
|
||||
const selection = terminal.current?.getSelection();
|
||||
if (selection) copySelection(selection);
|
||||
if (ref.current) {
|
||||
terminal.current.open(ref.current);
|
||||
// Hide cursor for read-only terminal using ANSI escape sequence
|
||||
terminal.current.write("\x1b[?25l");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleEnter = (command: string) => {
|
||||
terminal.current?.write("\r\n");
|
||||
// Don't write the command again as it will be added to the commands array
|
||||
// and rendered by the useEffect that watches commands
|
||||
send(getTerminalCommand(command));
|
||||
// Don't add the prompt here as it will be added when the command is processed
|
||||
// and the commands array is updated
|
||||
};
|
||||
|
||||
const handleBackspace = (command: string) => {
|
||||
terminal.current?.write("\b \b");
|
||||
return command.slice(0, -1);
|
||||
};
|
||||
|
||||
// Initialize terminal and handle cleanup
|
||||
@@ -134,11 +86,12 @@ export const useTerminal = () => {
|
||||
}
|
||||
lastCommandIndex.current = commands.length;
|
||||
}
|
||||
terminal.current.write("$ ");
|
||||
// Don't show prompt in read-only terminal
|
||||
}
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
lastCommandIndex.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -148,19 +101,17 @@ export const useTerminal = () => {
|
||||
commands.length > 0 &&
|
||||
lastCommandIndex.current < commands.length
|
||||
) {
|
||||
let lastCommandType = "";
|
||||
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
|
||||
lastCommandType = commands[i].type;
|
||||
if (commands[i].type === "input") {
|
||||
terminal.current.write("$ ");
|
||||
}
|
||||
// Pass true for isUserInput to skip rendering user input commands
|
||||
// that have already been displayed as the user typed
|
||||
renderCommand(commands[i], terminal.current, true);
|
||||
renderCommand(commands[i], terminal.current, false);
|
||||
}
|
||||
lastCommandIndex.current = commands.length;
|
||||
if (lastCommandType === "output") {
|
||||
terminal.current.write("$ ");
|
||||
}
|
||||
}
|
||||
}, [commands, disabled]);
|
||||
}, [commands]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
@@ -178,60 +129,5 @@ export const useTerminal = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (terminal.current) {
|
||||
// Dispose of existing listeners if they exist
|
||||
if (keyEventDisposable.current) {
|
||||
keyEventDisposable.current.dispose();
|
||||
keyEventDisposable.current = null;
|
||||
}
|
||||
|
||||
let commandBuffer = "";
|
||||
|
||||
if (!disabled) {
|
||||
// Add new key event listener and store the disposable
|
||||
keyEventDisposable.current = terminal.current.onKey(
|
||||
({ key, domEvent }) => {
|
||||
if (domEvent.key === "Enter") {
|
||||
handleEnter(commandBuffer);
|
||||
commandBuffer = "";
|
||||
} else if (domEvent.key === "Backspace") {
|
||||
if (commandBuffer.length > 0) {
|
||||
commandBuffer = handleBackspace(commandBuffer);
|
||||
}
|
||||
} else {
|
||||
// Ignore paste event
|
||||
if (key.charCodeAt(0) === 22) {
|
||||
return;
|
||||
}
|
||||
commandBuffer += key;
|
||||
terminal.current?.write(key);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add custom key handler and store the disposable
|
||||
terminal.current.attachCustomKeyEventHandler((event) =>
|
||||
pasteHandler(event, (text) => {
|
||||
commandBuffer += text;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Add a noop handler when disabled
|
||||
keyEventDisposable.current = terminal.current.onKey((e) => {
|
||||
e.domEvent.preventDefault();
|
||||
e.domEvent.stopPropagation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (keyEventDisposable.current) {
|
||||
keyEventDisposable.current.dispose();
|
||||
keyEventDisposable.current = null;
|
||||
}
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current WebSocket status
|
||||
@@ -9,11 +10,15 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont
|
||||
* - For V1 conversations: Returns status from ConversationWebSocketProvider
|
||||
*/
|
||||
export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0Status = useWsClient();
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
// Check if this is a V1 conversation:
|
||||
const isV1Conversation =
|
||||
conversationId.startsWith("task-") ||
|
||||
conversation?.conversation_version === "V1";
|
||||
|
||||
const webSocketStatus = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
@@ -33,7 +38,13 @@ export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
}
|
||||
}
|
||||
return v0Status.webSocketStatus;
|
||||
}, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
|
||||
}, [
|
||||
isV1Conversation,
|
||||
v1Context,
|
||||
v0Status.webSocketStatus,
|
||||
conversationId,
|
||||
conversation,
|
||||
]);
|
||||
|
||||
return webSocketStatus;
|
||||
}
|
||||
|
||||
10
frontend/src/hooks/use-v0-handle-runtime-active.ts
Normal file
10
frontend/src/hooks/use-v0-handle-runtime-active.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
export const useV0HandleRuntimeActive = () => {
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
return { runtimeActive };
|
||||
};
|
||||
48
frontend/src/hooks/use-v0-handle-ws-events.ts
Normal file
48
frontend/src/hooks/use-v0-handle-ws-events.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isServerError = (data: object): data is ServerError => "error" in data;
|
||||
|
||||
export const useV0HandleWSEvents = () => {
|
||||
const { send } = useWsClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
const event = events[events.length - 1];
|
||||
|
||||
if (isServerError(event)) {
|
||||
if (event.error_code === 401) {
|
||||
displayErrorToast("Session expired.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.error === "string") {
|
||||
displayErrorToast(event.error);
|
||||
} else {
|
||||
displayErrorToast(event.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ("type" in event && event.type === "error") {
|
||||
const message: string = `${event.message}`;
|
||||
if (message.startsWith("Agent reached maximum")) {
|
||||
// We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations
|
||||
send(generateAgentStateChangeEvent(AgentState.PAUSED));
|
||||
}
|
||||
}
|
||||
}, [events.length]);
|
||||
};
|
||||
@@ -930,4 +930,5 @@ export enum I18nKey {
|
||||
TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION",
|
||||
TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
|
||||
TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
|
||||
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
|
||||
}
|
||||
|
||||
@@ -6160,20 +6160,20 @@
|
||||
"uk": "Введіть свій ключ API."
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY": {
|
||||
"en": "LLM API Key",
|
||||
"zh-CN": "LLM API 密钥",
|
||||
"zh-TW": "LLM API 金鑰",
|
||||
"de": "LLM API Schlüssel",
|
||||
"ko-KR": "LLM API 키",
|
||||
"no": "LLM API-nøkkel",
|
||||
"it": "Chiave API LLM",
|
||||
"pt": "Chave API LLM",
|
||||
"es": "Clave API LLM",
|
||||
"ar": "مفتاح API للنماذج اللغوية الكبيرة",
|
||||
"fr": "Clé API LLM",
|
||||
"tr": "LLM API Anahtarı",
|
||||
"ja": "LLM APIキー",
|
||||
"uk": "Ключ API LLM"
|
||||
"en": "OpenHands LLM Key",
|
||||
"zh-CN": "OpenHands LLM 密钥",
|
||||
"zh-TW": "OpenHands LLM 金鑰",
|
||||
"de": "OpenHands LLM Schlüssel",
|
||||
"ko-KR": "OpenHands LLM 키",
|
||||
"no": "OpenHands LLM-nøkkel",
|
||||
"it": "Chiave LLM OpenHands",
|
||||
"pt": "Chave LLM OpenHands",
|
||||
"es": "Clave LLM OpenHands",
|
||||
"ar": "مفتاح LLM OpenHands",
|
||||
"fr": "Clé LLM OpenHands",
|
||||
"tr": "OpenHands LLM Anahtarı",
|
||||
"ja": "OpenHands LLMキー",
|
||||
"uk": "Ключ LLM OpenHands"
|
||||
},
|
||||
"SETTINGS$LLM_API_KEY_DESCRIPTION": {
|
||||
"en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.",
|
||||
@@ -14320,20 +14320,20 @@
|
||||
"uk": "Зупинити сервер"
|
||||
},
|
||||
"COMMON$TERMINAL": {
|
||||
"en": "Terminal",
|
||||
"ja": "ターミナル",
|
||||
"zh-CN": "终端",
|
||||
"zh-TW": "終端機",
|
||||
"ko-KR": "터미널",
|
||||
"no": "Terminal",
|
||||
"it": "Terminale",
|
||||
"pt": "Terminal",
|
||||
"es": "Terminal",
|
||||
"ar": "الطرفية",
|
||||
"fr": "Terminal",
|
||||
"tr": "Terminal",
|
||||
"de": "Terminal",
|
||||
"uk": "Термінал"
|
||||
"en": "Terminal (read-only)",
|
||||
"ja": "ターミナル (読み取り専用)",
|
||||
"zh-CN": "终端(只读)",
|
||||
"zh-TW": "終端機(唯讀)",
|
||||
"ko-KR": "터미널 (읽기 전용)",
|
||||
"no": "Terminal (skrivebeskyttet)",
|
||||
"it": "Terminale (sola lettura)",
|
||||
"pt": "Terminal (somente leitura)",
|
||||
"es": "Terminal (solo lectura)",
|
||||
"ar": "الطرفية (للقراءة فقط)",
|
||||
"fr": "Terminal (lecture seule)",
|
||||
"tr": "Terminal (salt okunur)",
|
||||
"de": "Terminal (schreibgeschützt)",
|
||||
"uk": "Термінал (тільки читання)"
|
||||
},
|
||||
"COMMON$UNKNOWN": {
|
||||
"en": "Unknown",
|
||||
@@ -14878,5 +14878,21 @@
|
||||
"tr": "Konuşma durduruldu",
|
||||
"de": "Konversation gestoppt",
|
||||
"uk": "Розмову зупинено"
|
||||
},
|
||||
"AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION": {
|
||||
"en": "Waiting for user confirmation",
|
||||
"ja": "ユーザーの確認を待っています",
|
||||
"zh-CN": "等待用户确认",
|
||||
"zh-TW": "等待使用者確認",
|
||||
"ko-KR": "사용자 확인 대기 중",
|
||||
"no": "Venter på brukerbekreftelse",
|
||||
"it": "In attesa di conferma dell'utente",
|
||||
"pt": "Aguardando confirmação do usuário",
|
||||
"es": "Esperando confirmación del usuario",
|
||||
"ar": "في انتظار تأكيد المستخدم",
|
||||
"fr": "En attente de la confirmation de l'utilisateur",
|
||||
"tr": "Kullanıcı onayı bekleniyor",
|
||||
"de": "Warte auf Benutzerbestätigung",
|
||||
"uk": "Очікується підтвердження користувача"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="113" viewBox="0 0 113 113" fill="none">
|
||||
<path d="M57.9521 83.6306C57.2282 83.6659 56.3772 83.6871 55.5226 83.6871C41.334 83.6871 28.5015 77.8853 19.2672 68.5204L19.2602 68.5134C22.177 76.7765 27.3821 83.6271 34.1162 88.4967L34.2362 88.5814C40.8185 93.3522 49.0569 96.216 57.9627 96.216C66.8685 96.216 75.1034 93.3557 81.8022 88.5038L81.6821 88.585C88.5327 83.6306 93.7378 76.7835 96.5663 68.81L96.6546 68.5204C87.4062 77.8888 74.5631 83.6907 60.3675 83.6907C59.5164 83.6907 58.6689 83.6695 57.8285 83.6271L57.9521 83.6306ZM57.9486 24.9624C58.676 24.9236 59.527 24.9024 60.3851 24.9024C74.5702 24.9024 87.4027 30.7043 96.6334 40.0656L96.6405 40.0727C93.7237 31.8095 88.5186 24.9589 81.788 20.0893L81.668 20.0046C75.0857 15.2374 66.8473 12.3806 57.9415 12.3806C49.0357 12.3806 40.8008 15.2374 34.0985 20.0893L34.2186 20.0081C27.3644 24.9589 22.1593 31.8095 19.3308 39.7831L19.2425 40.0727C28.5015 30.7078 41.3517 24.9059 55.5579 24.9059C56.3983 24.9059 57.2388 24.9271 58.0686 24.966L57.9486 24.9624ZM25.5776 18.2672C25.5776 19.549 25.0585 20.7108 24.2216 21.5548C23.3882 22.3952 22.2335 22.9178 20.9587 22.9178C19.6839 22.9178 18.5257 22.3952 17.6958 21.5548C16.8589 20.7108 16.3398 19.5455 16.3398 18.2637C16.3398 16.9818 16.8589 15.8165 17.6958 14.9725C18.5292 14.1321 19.6839 13.6095 20.9587 13.6095C22.2335 13.6095 23.3918 14.1321 24.2251 14.9761C25.062 15.82 25.5776 16.9818 25.5776 18.2672ZM94.3734 9.84516C94.3734 9.84869 94.3734 9.85222 94.3734 9.85222C94.3734 11.5861 93.6742 13.1575 92.5442 14.2981C91.4178 15.4387 89.8534 16.1449 88.1266 16.1449C86.3998 16.1449 84.8355 15.4387 83.709 14.3016C82.579 13.1575 81.8798 11.5825 81.8798 9.84516C81.8798 8.10779 82.579 6.53285 83.709 5.38872C84.8355 4.25166 86.3963 3.54541 88.1266 3.54541C89.8569 3.54541 91.4178 4.25166 92.5442 5.38872C93.6742 6.52932 94.3734 8.10072 94.3734 9.8381V9.84516ZM35.1296 101.516C35.1296 101.516 35.1296 101.52 35.1296 101.523C35.1296 103.709 34.2503 105.69 32.8237 107.131C31.4042 108.568 29.4337 109.458 27.2585 109.458C25.0832 109.458 23.1128 108.568 21.6932 107.135C20.2666 105.694 19.3873 103.709 19.3873 101.52C19.3873 99.3306 20.2666 97.3495 21.6932 95.9053C23.1128 94.468 25.0797 93.5817 27.2585 93.5817C29.4372 93.5817 31.4042 94.4716 32.8237 95.9088C34.2468 97.3495 35.1296 99.3306 35.1296 101.516C35.1296 101.52 35.1296 101.52 35.1296 101.523V101.516Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(0.85) translate(1.8, 1.8)">
|
||||
<path d="M19.5 1.5C18.67 1.5 18 2.17 18 3C18 3.83 18.67 4.5 19.5 4.5C20.33 4.5 21 3.83 21 3C21 2.17 20.33 1.5 19.5 1.5Z" fill="currentColor"/>
|
||||
<path d="M12 18C8.5 18 5.5 16.8 4 15C4 18.3137 7.13401 21 12 21C16.866 21 20 18.3137 20 15C18.5 16.8 15.5 18 12 18Z" fill="currentColor"/>
|
||||
<path d="M12 6C15.5 6 18.5 7.2 20 9C20 5.68629 16.866 3 12 3C7.13401 3 4 5.68629 4 9C5.5 7.2 8.5 6 12 6Z" fill="currentColor"/>
|
||||
<path d="M7.5 21C6.67 21 6 21.67 6 22.5C6 23.33 6.67 24 7.5 24C8.33 24 9 23.33 9 22.5C9 21.67 8.33 21 7.5 21Z" fill="currentColor"/>
|
||||
<path d="M4.5 5.5C3.67 5.5 3 4.83 3 4C3 3.17 3.67 2.5 4.5 2.5C5.33 2.5 6 3.17 6 4C6 4.83 5.33 5.5 4.5 5.5Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 831 B |
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
|
||||
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { RandomTip } from "#/components/features/tips/random-tip";
|
||||
@@ -27,7 +27,7 @@ function GitChanges() {
|
||||
isError,
|
||||
error,
|
||||
isLoading: loadingGitChanges,
|
||||
} = useGetGitChanges();
|
||||
} = useUnifiedGetGitChanges();
|
||||
|
||||
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
|
||||
null,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -53,7 +52,6 @@ function AppContent() {
|
||||
const setCurrentAgentState = useAgentStore(
|
||||
(state) => state.setCurrentAgentState,
|
||||
);
|
||||
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
|
||||
const removeErrorMessage = useErrorMessageStore(
|
||||
(state) => state.removeErrorMessage,
|
||||
);
|
||||
@@ -70,7 +68,6 @@ function AppContent() {
|
||||
// 1. Cleanup Effect - runs when navigating to a different conversation
|
||||
React.useEffect(() => {
|
||||
clearTerminal();
|
||||
clearJupyter();
|
||||
resetConversationState();
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
removeErrorMessage();
|
||||
@@ -84,7 +81,6 @@ function AppContent() {
|
||||
}, [
|
||||
conversationId,
|
||||
clearTerminal,
|
||||
clearJupyter,
|
||||
resetConversationState,
|
||||
setCurrentAgentState,
|
||||
removeErrorMessage,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
|
||||
function Jupyter() {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [parentWidth, setParentWidth] = React.useState(0);
|
||||
|
||||
// This is a hack to prevent the editor from overflowing
|
||||
// Should be removed after revising the parent and containers
|
||||
// Use ResizeObserver to properly track parent width changes
|
||||
React.useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
// Use contentRect.width for more accurate measurements
|
||||
const { width } = entry.contentRect;
|
||||
if (width > 0) {
|
||||
setParentWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (parentRef.current) {
|
||||
resizeObserver.observe(parentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Provide a fallback width to prevent the editor from being hidden
|
||||
// Use parentWidth if available, otherwise use a large default
|
||||
const maxWidth = parentWidth > 0 ? parentWidth : 9999;
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full">
|
||||
<JupyterEditor maxWidth={maxWidth} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Jupyter;
|
||||
@@ -28,12 +28,6 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { getProviderId } from "#/utils/map-provider";
|
||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { UpgradeBannerWithBackdrop } from "#/components/features/settings/upgrade-banner-with-backdrop";
|
||||
import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/use-create-subscription-checkout-session";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useIsAllHandsSaaSEnvironment } from "#/hooks/use-is-all-hands-saas-environment";
|
||||
|
||||
interface OpenHandsApiKeyHelpProps {
|
||||
testId: string;
|
||||
@@ -75,11 +69,6 @@ function LlmSettingsScreen() {
|
||||
const { data: resources } = useAIConfigOptions();
|
||||
const { data: settings, isLoading, isFetching } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { mutate: createSubscriptionCheckoutSession } =
|
||||
useCreateSubscriptionCheckoutSession();
|
||||
const isAllHandsSaaSEnvironment = useIsAllHandsSaaSEnvironment();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
|
||||
@@ -442,44 +431,16 @@ function LlmSettingsScreen() {
|
||||
|
||||
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
|
||||
|
||||
// Show upgrade banner and disable form in SaaS mode when user doesn't have an active subscription
|
||||
// Exclude self-hosted enterprise customers (those not on all-hands.dev domains)
|
||||
const shouldShowUpgradeBanner =
|
||||
config?.APP_MODE === "saas" &&
|
||||
!subscriptionAccess &&
|
||||
isAllHandsSaaSEnvironment;
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
// Prevent form submission for unsubscribed SaaS users
|
||||
if (shouldShowUpgradeBanner) return;
|
||||
|
||||
if (view === "basic") basicFormAction(formData);
|
||||
else advancedFormAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="llm-settings-screen"
|
||||
className={cn(
|
||||
"h-full relative",
|
||||
shouldShowUpgradeBanner && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{shouldShowUpgradeBanner && (
|
||||
<UpgradeBannerWithBackdrop
|
||||
onUpgradeClick={() => {
|
||||
createSubscriptionCheckoutSession();
|
||||
}}
|
||||
isDisabled={!isAuthed}
|
||||
/>
|
||||
)}
|
||||
<div data-testid="llm-settings-screen" className="h-full relative">
|
||||
<form
|
||||
action={formAction}
|
||||
className={cn(
|
||||
"flex flex-col h-full justify-between",
|
||||
shouldShowUpgradeBanner && "h-[calc(100%-theme(spacing.12))]",
|
||||
)}
|
||||
inert={shouldShowUpgradeBanner}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
@@ -487,7 +448,6 @@ function LlmSettingsScreen() {
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
@@ -496,7 +456,6 @@ function LlmSettingsScreen() {
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
aria-disabled={shouldShowUpgradeBanner ? "true" : undefined}
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<>
|
||||
@@ -504,7 +463,6 @@ function LlmSettingsScreen() {
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
|
||||
onChange={handleModelIsDirty}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
wrapperClassName="!flex-col !gap-6"
|
||||
/>
|
||||
{(settings.LLM_MODEL?.startsWith("openhands/") ||
|
||||
@@ -522,7 +480,6 @@ function LlmSettingsScreen() {
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
@@ -602,7 +559,6 @@ function LlmSettingsScreen() {
|
||||
defaultValue={settings.SEARCH_API_KEY || ""}
|
||||
onChange={handleSearchApiKeyIsDirty}
|
||||
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
startContent={
|
||||
settings.SEARCH_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
|
||||
@@ -672,7 +628,6 @@ function LlmSettingsScreen() {
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
isDisabled={shouldShowUpgradeBanner}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { EventHandler } from "#/wrapper/event-handler";
|
||||
import { V0EventHandler } from "#/wrapper/v0-event-handler";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
@@ -18,9 +18,9 @@ export const clientLoader = async () => {
|
||||
function MicroagentManagement() {
|
||||
return (
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<V0EventHandler>
|
||||
<MicroagentManagementContent />
|
||||
</EventHandler>
|
||||
</V0EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import React from "react";
|
||||
import { FaArrowRotateRight } from "react-icons/fa6";
|
||||
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
|
||||
import { PathForm } from "#/components/features/served-host/path-form";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
|
||||
function ServedApp() {
|
||||
const { t } = useTranslation();
|
||||
const { activeHost } = useActiveHost();
|
||||
const { activeHost } = useUnifiedActiveHost();
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [currentActiveHost, setCurrentActiveHost] = React.useState<
|
||||
string | null
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Route } from "./+types/settings";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { SettingsLayout } from "#/components/features/settings/settings-layout";
|
||||
@@ -41,7 +40,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const location = useLocation();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
@@ -55,7 +53,7 @@ function SettingsScreen() {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
}, [isSaas]);
|
||||
|
||||
// Current section title for the main content area
|
||||
const currentSectionTitle = useMemo(() => {
|
||||
@@ -65,7 +63,7 @@ function SettingsScreen() {
|
||||
|
||||
return (
|
||||
<main data-testid="settings-screen" className="h-full">
|
||||
<SettingsLayout navigationItems={navItems} isSaas={isSaas}>
|
||||
<SettingsLayout navigationItems={navItems}>
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
|
||||
<div className="flex-1 overflow-auto custom-scrollbar-always">
|
||||
|
||||
@@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useVSCodeUrl();
|
||||
const { data, isLoading, error } = useUnifiedVSCodeUrl();
|
||||
const { curAgentState } = useAgentState();
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
@@ -39,10 +39,18 @@ function VSCodeTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isRuntimeInactive || isLoading) {
|
||||
if (isRuntimeInactive) {
|
||||
return <WaitingForRuntimeMessage />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t(I18nKey.VSCODE$LOADING)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || (data && data.error) || !data?.url || iframeError) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import {
|
||||
@@ -35,10 +34,6 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
useCommandStore.getState().appendInput(message.args.command);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN_IPYTHON) {
|
||||
useJupyterStore.getState().appendJupyterInput(message.args.code);
|
||||
}
|
||||
|
||||
if ("args" in message && "security_risk" in message.args) {
|
||||
useSecurityAnalyzerStore.getState().appendSecurityAnalyzerInput({
|
||||
id: message.id,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import ObservationType from "#/types/observation-type";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
@@ -22,14 +21,6 @@ export function handleObservationMessage(message: ObservationMessage) {
|
||||
useCommandStore.getState().appendOutput(content);
|
||||
break;
|
||||
}
|
||||
case ObservationType.RUN_IPYTHON:
|
||||
useJupyterStore.getState().appendJupyterOutput({
|
||||
content: message.content,
|
||||
imageUrls: Array.isArray(message.extras?.image_urls)
|
||||
? message.extras.image_urls
|
||||
: undefined,
|
||||
});
|
||||
break;
|
||||
case ObservationType.BROWSE:
|
||||
case ObservationType.BROWSE_INTERACTIVE:
|
||||
if (
|
||||
|
||||
@@ -4,7 +4,6 @@ import { devtools } from "zustand/middleware";
|
||||
export type ConversationTab =
|
||||
| "editor"
|
||||
| "browser"
|
||||
| "jupyter"
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal";
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Cell = {
|
||||
content: string;
|
||||
type: "input" | "output";
|
||||
imageUrls?: string[];
|
||||
};
|
||||
|
||||
interface JupyterState {
|
||||
cells: Cell[];
|
||||
appendJupyterInput: (content: string) => void;
|
||||
appendJupyterOutput: (payload: {
|
||||
content: string;
|
||||
imageUrls?: string[];
|
||||
}) => void;
|
||||
clearJupyter: () => void;
|
||||
}
|
||||
|
||||
export const useJupyterStore = create<JupyterState>((set) => ({
|
||||
cells: [],
|
||||
appendJupyterInput: (content: string) =>
|
||||
set((state) => ({
|
||||
cells: [...state.cells, { content, type: "input" }],
|
||||
})),
|
||||
appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) =>
|
||||
set((state) => ({
|
||||
cells: [
|
||||
...state.cells,
|
||||
{
|
||||
content: payload.content,
|
||||
type: "output",
|
||||
imageUrls: payload.imageUrls,
|
||||
},
|
||||
],
|
||||
})),
|
||||
clearJupyter: () =>
|
||||
set(() => ({
|
||||
cells: [],
|
||||
})),
|
||||
}));
|
||||
@@ -2,15 +2,19 @@ import { create } from "zustand";
|
||||
|
||||
interface EventMessageState {
|
||||
submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons
|
||||
v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs)
|
||||
}
|
||||
|
||||
interface EventMessageStore extends EventMessageState {
|
||||
addSubmittedEventId: (id: number) => void;
|
||||
removeSubmittedEventId: (id: number) => void;
|
||||
addV1SubmittedEventId: (id: string) => void;
|
||||
removeV1SubmittedEventId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
submittedEventIds: [],
|
||||
v1SubmittedEventIds: [],
|
||||
addSubmittedEventId: (id: number) =>
|
||||
set((state) => ({
|
||||
submittedEventIds: [...state.submittedEventIds, id],
|
||||
@@ -21,4 +25,14 @@ export const useEventMessageStore = create<EventMessageStore>((set) => ({
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
addV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: [...state.v1SubmittedEventIds, id],
|
||||
})),
|
||||
removeV1SubmittedEventId: (id: string) =>
|
||||
set((state) => ({
|
||||
v1SubmittedEventIds: state.v1SubmittedEventIds.filter(
|
||||
(eventId) => eventId !== id,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
enum TabOption {
|
||||
PLANNER = "planner",
|
||||
BROWSER = "browser",
|
||||
JUPYTER = "jupyter",
|
||||
VSCODE = "vscode",
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| TabOption.PLANNER
|
||||
| TabOption.BROWSER
|
||||
| TabOption.JUPYTER
|
||||
| TabOption.VSCODE;
|
||||
|
||||
const AllTabs = [
|
||||
TabOption.VSCODE,
|
||||
TabOption.BROWSER,
|
||||
TabOption.PLANNER,
|
||||
TabOption.JUPYTER,
|
||||
];
|
||||
type TabType = TabOption.PLANNER | TabOption.BROWSER | TabOption.VSCODE;
|
||||
const AllTabs = [TabOption.VSCODE, TabOption.BROWSER, TabOption.PLANNER];
|
||||
|
||||
export { AllTabs, TabOption, type TabType };
|
||||
|
||||
@@ -24,6 +24,7 @@ export const handleActionEventCacheInvalidation = (
|
||||
// Invalidate file_changes cache for file-related actions
|
||||
if (
|
||||
action.kind === "StrReplaceEditorAction" ||
|
||||
action.kind === "FileEditorAction" ||
|
||||
action.kind === "ExecuteBashAction"
|
||||
) {
|
||||
queryClient.invalidateQueries(
|
||||
@@ -35,7 +36,11 @@ export const handleActionEventCacheInvalidation = (
|
||||
}
|
||||
|
||||
// Invalidate specific file diff cache for file modifications
|
||||
if (action.kind === "StrReplaceEditorAction" && action.path) {
|
||||
if (
|
||||
(action.kind === "StrReplaceEditorAction" ||
|
||||
action.kind === "FileEditorAction") &&
|
||||
action.path
|
||||
) {
|
||||
const strippedPath = stripWorkspacePrefix(action.path);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["file_diff", conversationId, strippedPath],
|
||||
|
||||
22
frontend/src/utils/get-git-path.ts
Normal file
22
frontend/src/utils/get-git-path.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Get the git repository path for a conversation
|
||||
* If a repository is selected, returns /workspace/project/{repo-name}
|
||||
* Otherwise, returns /workspace/project
|
||||
*
|
||||
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo")
|
||||
* @returns The git path to use
|
||||
*/
|
||||
export function getGitPath(
|
||||
selectedRepository: string | null | undefined,
|
||||
): string {
|
||||
if (!selectedRepository) {
|
||||
return "/workspace/project";
|
||||
}
|
||||
|
||||
// Extract the repository name from "owner/repo" format
|
||||
// The folder name is the second part after "/"
|
||||
const parts = selectedRepository.split("/");
|
||||
const repoName = parts.length > 1 ? parts[1] : parts[0];
|
||||
|
||||
return `/workspace/project/${repoName}`;
|
||||
}
|
||||
27
frontend/src/utils/git-status-mapper.ts
Normal file
27
frontend/src/utils/git-status-mapper.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
GitChangeStatus,
|
||||
V1GitChangeStatus,
|
||||
} from "#/api/open-hands.types";
|
||||
|
||||
/**
|
||||
* Maps V1 git change status to legacy V0 status format
|
||||
*
|
||||
* V1 -> V0 mapping:
|
||||
* - ADDED -> A (Added)
|
||||
* - DELETED -> D (Deleted)
|
||||
* - UPDATED -> M (Modified)
|
||||
* - MOVED -> R (Renamed)
|
||||
*
|
||||
* @param v1Status The V1 git change status
|
||||
* @returns The equivalent V0 git change status
|
||||
*/
|
||||
export function mapV1ToV0Status(v1Status: V1GitChangeStatus): GitChangeStatus {
|
||||
const statusMap: Record<V1GitChangeStatus, GitChangeStatus> = {
|
||||
ADDED: "A",
|
||||
DELETED: "D",
|
||||
UPDATED: "M",
|
||||
MOVED: "R",
|
||||
};
|
||||
|
||||
return statusMap[v1Status];
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
|
||||
/**
|
||||
* Handles adding an event to the UI events array, with special logic for observation events
|
||||
* Handles adding an event to the UI events array
|
||||
* Replaces actions with observations when they arrive (so UI shows observation instead of action)
|
||||
*/
|
||||
export const handleEventForUI = (
|
||||
event: OpenHandsEvent,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export type JupyterLine = {
|
||||
type: "plaintext" | "image";
|
||||
content: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export const parseCellContent = (content: string, imageUrls?: string[]) => {
|
||||
const lines: JupyterLine[] = [];
|
||||
let currentText = "";
|
||||
|
||||
// First, process the text content
|
||||
for (const line of content.split("\n")) {
|
||||
currentText += `${line}\n`;
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
lines.push({ type: "plaintext", content: currentText });
|
||||
}
|
||||
|
||||
// Then, add image lines if we have image URLs
|
||||
if (imageUrls && imageUrls.length > 0) {
|
||||
imageUrls.forEach((url) => {
|
||||
lines.push({
|
||||
type: "image",
|
||||
content: ``,
|
||||
url,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user