diff --git a/.github/workflows/eval-runner.yml b/.github/workflows/eval-runner.yml
index f788cf78d2f8..6f1c225efee6 100644
--- a/.github/workflows/eval-runner.yml
+++ b/.github/workflows/eval-runner.yml
@@ -3,8 +3,6 @@ name: Run Evaluation
on:
pull_request:
types: [labeled]
- schedule:
- - cron: "0 1 * * *" # Run daily at 1 AM UTC
workflow_dispatch:
inputs:
reason:
diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml
index a2b82232eaaf..f24a8e90cbfb 100644
--- a/.github/workflows/openhands-resolver.yml
+++ b/.github/workflows/openhands-resolver.yml
@@ -11,6 +11,11 @@ on:
required: false
type: string
default: "@openhands-agent"
+ target_branch:
+ required: false
+ type: string
+ default: "main"
+ description: "Target branch to pull and create PR against"
secrets:
LLM_MODEL:
required: true
@@ -48,12 +53,12 @@ jobs:
(
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
- startsWith(github.event.comment.body, inputs.macro || '@openhands-agent') &&
+ contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
) ||
(github.event_name == 'pull_request_review' &&
- startsWith(github.event.review.body, inputs.macro || '@openhands-agent') &&
+ contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
@@ -80,11 +85,11 @@ jobs:
github.event.label.name == 'fix-me-experimental' ||
(
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
- startsWith(github.event.comment.body, '@openhands-agent-exp')
+ contains(github.event.comment.body, '@openhands-agent-exp')
) ||
(
github.event_name == 'pull_request_review' &&
- startsWith(github.event.review.body, '@openhands-agent-exp')
+ contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: actions/cache@v3
@@ -135,6 +140,9 @@ jobs:
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
+ # Set branch variables
+ echo "TARGET_BRANCH=${{ inputs.target_branch }}" >> $GITHUB_ENV
+
- name: Comment on issue with start message
uses: actions/github-script@v7
with:
@@ -175,7 +183,7 @@ jobs:
--issue-number ${{ env.ISSUE_NUMBER }} \
--issue-type ${{ env.ISSUE_TYPE }} \
--max-iterations ${{ env.MAX_ITERATIONS }} \
- --comment-id ${{ env.COMMENT_ID }}
+ --comment-id ${{ env.COMMENT_ID }} \
- name: Check resolution result
id: check_result
diff --git a/.github/workflows/review-pr.yml b/.github/workflows/review-pr.yml
deleted file mode 100644
index bc3d103e6413..000000000000
--- a/.github/workflows/review-pr.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-# Workflow that uses OpenHands to review a pull request. PR must be labeled 'review-this'
-name: Use OpenHands to Review Pull Request
-
-on:
- pull_request:
- types: [synchronize, labeled]
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- dogfood:
- if: contains(github.event.pull_request.labels.*.name, 'review-this')
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@v3
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.12'
- - name: install git, github cli
- run: |
- sudo apt-get install -y git gh
- git config --global --add safe.directory $PWD
- - name: Checkout Repository
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.base.ref }} # check out the target branch
- - name: Download Diff
- run: |
- curl -O "${{ github.event.pull_request.diff_url }}" -L
- - name: Write Task File
- run: |
- echo "Your coworker wants to apply a pull request to this project." > task.txt
- echo "Read and review ${{ github.event.pull_request.number }}.diff file. Create a review-${{ github.event.pull_request.number }}.txt and write your concise comments and suggestions there." >> task.txt
- echo "Do not ask me for confirmation at any point." >> task.txt
- echo "" >> task.txt
- echo "Title" >> task.txt
- echo "${{ github.event.pull_request.title }}" >> task.txt
- echo "" >> task.txt
- echo "Description" >> task.txt
- echo "${{ github.event.pull_request.body }}" >> task.txt
- echo "" >> task.txt
- echo "Diff file is: ${{ github.event.pull_request.number }}.diff" >> task.txt
- - name: Set up environment
- run: |
- curl -sSL https://install.python-poetry.org | python3 -
- export PATH="/github/home/.local/bin:$PATH"
- poetry install --without evaluation,llama-index
- poetry run playwright install --with-deps chromium
- - name: Run OpenHands
- env:
- LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
- LLM_MODEL: ${{ vars.LLM_MODEL }}
- run: |
- # Append path to launch poetry
- export PATH="/github/home/.local/bin:$PATH"
- # Append path to correctly import package, note: must set pwd at first
- export PYTHONPATH=$(pwd):$PYTHONPATH
- export WORKSPACE_MOUNT_PATH=$GITHUB_WORKSPACE
- export WORKSPACE_BASE=$GITHUB_WORKSPACE
- echo -e "/exit\n" | poetry run python openhands/core/main.py -i 50 -f task.txt
- rm task.txt
- - name: Check if review file is non-empty
- id: check_file
- run: |
- ls -la
- if [[ -s review-${{ github.event.pull_request.number }}.txt ]]; then
- echo "non_empty=true" >> $GITHUB_OUTPUT
- fi
- shell: bash
- - name: Create PR review if file is non-empty
- env:
- GH_TOKEN: ${{ github.token }}
- if: steps.check_file.outputs.non_empty == 'true'
- run: |
- gh pr review ${{ github.event.pull_request.number }} --comment --body-file "review-${{ github.event.pull_request.number }}.txt"
diff --git a/.github/workflows/run-eval.yml b/.github/workflows/run-eval.yml
new file mode 100644
index 000000000000..df79872aec26
--- /dev/null
+++ b/.github/workflows/run-eval.yml
@@ -0,0 +1,53 @@
+# Run evaluation on a PR
+name: Run Eval
+
+# Runs when a PR is labeled with one of the "run-eval-" labels
+on:
+ pull_request:
+ types: [labeled]
+
+jobs:
+ trigger-job:
+ name: Trigger remote eval job
+ if: ${{ github.event.label.name == 'run-eval-xs' || github.event.label.name == 'run-eval-s' || github.event.label.name == 'run-eval-m' }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v3
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Trigger remote job
+ run: |
+ REPO_URL="https://github.com/${{ github.repository }}"
+ PR_BRANCH="${{ github.head_ref }}"
+ echo "Repository URL: $REPO_URL"
+ echo "PR Branch: $PR_BRANCH"
+
+ if [[ "${{ github.event.label.name }}" == "run-eval-xs" ]]; then
+ EVAL_INSTANCES="1"
+ elif [[ "${{ github.event.label.name }}" == "run-eval-s" ]]; then
+ EVAL_INSTANCES="5"
+ elif [[ "${{ github.event.label.name }}" == "run-eval-m" ]]; then
+ EVAL_INSTANCES="30"
+ fi
+
+ curl -X POST \
+ -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
+ -H "Accept: application/vnd.github+json" \
+ -d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
+ https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
+
+ # Send Slack message
+ PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
+ slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
+ curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
+ https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
+
+ - name: Comment on PR
+ uses: KeisukeYamashita/create-comment@v1
+ with:
+ unique: false
+ comment: |
+ Running evaluation on the PR. Once eval is done, the results will be posted.
diff --git a/.gitignore b/.gitignore
index 6d3108331456..b88b0cb607d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -175,6 +175,7 @@ evaluation/gaia/data
evaluation/gorilla/data
evaluation/toolqa/data
evaluation/scienceagentbench/benchmark
+evaluation/commit0_bench/repos
# openhands resolver
output/
diff --git a/README.md b/README.md
index 97de3104472c..77633dbbbf72 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,8 @@ See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details o
## 📈 Progress
+See the monthly OpenHands roadmap [here](https://github.com/orgs/All-Hands-AI/projects/1) (updated at the maintainer's meeting at the end of each month).
+
diff --git a/evaluation/commit0_bench/README.md b/evaluation/commit0_bench/README.md
new file mode 100644
index 000000000000..fdfd5812a8bc
--- /dev/null
+++ b/evaluation/commit0_bench/README.md
@@ -0,0 +1,82 @@
+# Commit0 Evaluation with OpenHands
+
+This folder contains the evaluation harness that we built on top of the original [Commit0](https://commit-0.github.io/) ([paper](TBD)).
+
+The evaluation consists of three steps:
+
+1. Environment setup: [install python environment](../README.md#development-environment), [configure LLM config](../README.md#configure-openhands-and-your-llm).
+2. [Run Evaluation](#run-inference-on-commit0-instances): Generate a edit patch for each Commit0 Repo, and get the evaluation results
+
+## Setup Environment and LLM Configuration
+
+Please follow instruction [here](../README.md#setup) to setup your local development environment and LLM.
+
+## OpenHands Commit0 Instance-level Docker Support
+
+OpenHands supports using the Commit0 Docker for **[inference](#run-inference-on-commit0-instances).
+This is now the default behavior.
+
+
+## Run Inference on Commit0 Instances
+
+Make sure your Docker daemon is running, and you have ample disk space (at least 200-500GB, depends on the Commit0 set you are running on) for the [instance-level docker image](#openhands-commit0-instance-level-docker-support).
+
+When the `run_infer.sh` script is started, it will automatically pull the `lite` split in Commit0. For example, for instance ID `commit-0/minitorch`, it will try to pull our pre-build docker image `wentingzhao/minitorch` from DockerHub. This image will be used create an OpenHands runtime image where the agent will operate on.
+
+```bash
+./evaluation/commit0_bench/scripts/run_infer.sh [repo_split] [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split]
+
+# Example
+./evaluation/commit0_bench/scripts/run_infer.sh lite llm.eval_sonnet HEAD CodeActAgent 16 100 8 wentingzhao/commit0_combined test
+```
+
+where `model_config` is mandatory, and the rest are optional.
+
+- `repo_split`, e.g. `lite`, is the split of the Commit0 dataset you would like to evaluate on. Available options are `lite`, `all` and each individual repo.
+- `model_config`, e.g. `eval_gpt4_1106_preview`, is the config group name for your
+LLM settings, as defined in your `config.toml`.
+- `git-version`, e.g. `HEAD`, is the git commit hash of the OpenHands version you would
+like to evaluate. It could also be a release tag like `0.6.2`.
+- `agent`, e.g. `CodeActAgent`, is the name of the agent for benchmarks, defaulting
+to `CodeActAgent`.
+- `eval_limit`, e.g. `10`, limits the evaluation to the first `eval_limit` instances. By
+default, the script evaluates the `lite` split of the Commit0 dataset (16 repos). Note:
+in order to use `eval_limit`, you must also set `agent`.
+- `max_iter`, e.g. `20`, is the maximum number of iterations for the agent to run. By
+default, it is set to 30.
+- `num_workers`, e.g. `3`, is the number of parallel workers to run the evaluation. By
+default, it is set to 1.
+- `dataset`, a huggingface dataset name. e.g. `wentingzhao/commit0_combined`, specifies which dataset to evaluate on.
+- `dataset_split`, split for the huggingface dataset. Notice only `test` is supported for Commit0.
+
+Note that the `USE_INSTANCE_IMAGE` environment variable is always set to `true` for Commit0.
+
+Let's say you'd like to run 10 instances using `llm.eval_sonnet` and CodeActAgent,
+
+then your command would be:
+
+```bash
+./evaluation/commit0_bench/scripts/run_infer.sh lite llm.eval_sonnet HEAD CodeActAgent 10 30 1 wentingzhao/commit0_combined test
+```
+
+### Run Inference on `RemoteRuntime` (experimental)
+
+This is in limited beta. Contact Xingyao over slack if you want to try this out!
+
+```bash
+./evaluation/commit0_bench/scripts/run_infer.sh [repo_split] [model_config] [git-version] [agent] [eval_limit] [max_iter] [num_workers] [dataset] [dataset_split]
+
+# Example - This runs evaluation on CodeActAgent for 10 instances on "wentingzhao/commit0_combined"'s test set, with max 30 iteration per instances, with 1 number of workers running in parallel
+ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="docker.io/wentingzhao" \
+./evaluation/commit0_bench/scripts/run_infer.sh lite llm.eval_sonnet HEAD CodeActAgent 10 30 1 wentingzhao/commit0_combined test
+```
+
+To clean-up all existing runtime you've already started, run:
+
+```bash
+ALLHANDS_API_KEY="YOUR-API-KEY" ./evaluation/commit0_bench/scripts/cleanup_remote_runtime.sh
+```
+
+### Specify a subset of tasks to run infer
+
+If you would like to specify a list of tasks you'd like to benchmark on, you just need to pass selected repo through `repo_split` option.
diff --git a/evaluation/commit0_bench/run_infer.py b/evaluation/commit0_bench/run_infer.py
new file mode 100644
index 000000000000..ef2df020310c
--- /dev/null
+++ b/evaluation/commit0_bench/run_infer.py
@@ -0,0 +1,606 @@
+import asyncio
+import json
+import os
+from collections import Counter
+from typing import Any
+
+import pandas as pd
+from commit0.harness.constants import SPLIT
+from datasets import load_dataset
+
+import openhands.agenthub
+from evaluation.utils.shared import (
+ EvalException,
+ EvalMetadata,
+ EvalOutput,
+ assert_and_raise,
+ codeact_user_response,
+ make_metadata,
+ prepare_dataset,
+ reset_logger_for_multiprocessing,
+ run_evaluation,
+ update_llm_config_for_completions_logging,
+)
+from openhands.controller.state.state import State
+from openhands.core.config import (
+ AgentConfig,
+ AppConfig,
+ SandboxConfig,
+ get_llm_config_arg,
+ get_parser,
+)
+from openhands.core.logger import openhands_logger as logger
+from openhands.core.main import create_runtime, run_controller
+from openhands.events.action import CmdRunAction, MessageAction
+from openhands.events.observation import CmdOutputObservation, ErrorObservation
+from openhands.events.serialization.event import event_to_dict
+from openhands.runtime.base import Runtime
+from openhands.utils.async_utils import call_async_from_sync
+from openhands.utils.shutdown_listener import sleep_if_should_continue
+
+USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
+USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'
+RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'true'
+
+AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
+ 'CodeActAgent': codeact_user_response,
+ 'CodeActCommit0Agent': codeact_user_response,
+}
+
+
+def _get_commit0_workspace_dir_name(instance: pd.Series) -> str:
+ return instance['repo'].split('/')[1]
+
+
+def get_instruction(instance: pd.Series, metadata: EvalMetadata):
+ workspace_dir_name = _get_commit0_workspace_dir_name(instance)
+ # Prepare instruction
+ test_cmd = instance['test']['test_cmd']
+ test_dir = instance['test']['test_dir']
+ # Instruction based on Anthropic's official trajectory
+ # https://github.com/eschluntz/swe-bench-experiments/tree/main/evaluation/verified/20241022_tools_claude-3-5-sonnet-updated/trajs
+ instruction = (
+ '\n'
+ f'/workspace/{workspace_dir_name}\n'
+ '\n'
+ f"I've uploaded a python code repository in the directory {workspace_dir_name}. Here is your task:\n\n"
+ 'Here is your task:\n\n'
+ ' You need to complete the implementations for all functions (i.e., those with pass\n'
+ ' statements) and pass the unit tests.\n\n'
+ ' Do not change the names of existing functions or classes, as they may be referenced\n'
+ ' from other code like unit tests, etc.\n\n'
+ ' When you generate code, you must maintain the original formatting of the function\n'
+ ' stubs (such as whitespaces), otherwise we will not able to search/replace blocks\n'
+ ' for code modifications, and therefore you will receive a score of 0 for your generated\n'
+ ' code.'
+ '\n\n'
+ 'Here is the command to run the unit tests:\n'
+ '\n'
+ f'{test_cmd} {test_dir}\n'
+ '\n\n'
+ 'Make a local git commit for each agent step for all code changes. If there is not change in current step, do not make a commit.'
+ )
+
+ if RUN_WITH_BROWSING:
+ instruction += (
+ '\n'
+ 'You SHOULD NEVER attempt to browse the web. '
+ '\n'
+ )
+ return instruction
+
+
+# TODO: migrate all swe-bench docker to ghcr.io/openhands
+DOCKER_IMAGE_PREFIX = os.environ.get(
+ 'EVAL_DOCKER_IMAGE_PREFIX', 'docker.io/wentingzhao/'
+)
+logger.info(f'Using docker image prefix: {DOCKER_IMAGE_PREFIX}')
+
+
+def get_instance_docker_image(repo_name: str) -> str:
+ return (DOCKER_IMAGE_PREFIX.rstrip('/') + '/' + repo_name).lower() + ':v0'
+
+
+def get_config(
+ instance: pd.Series,
+ metadata: EvalMetadata,
+) -> AppConfig:
+ # COMMIT0_CONTAINER_IMAGE = 'wentingzhao/'
+ assert USE_INSTANCE_IMAGE
+ # We use a different instance image for the each instance of commit0 eval
+ repo_name = instance['repo'].split('/')[1]
+ base_container_image = get_instance_docker_image(repo_name)
+ logger.info(
+ f'Using instance container image: {base_container_image}. '
+ f'Please make sure this image exists. '
+ f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
+ )
+ # else:
+ # raise
+ # base_container_image = SWE_BENCH_CONTAINER_IMAGE
+ # logger.info(f'Using swe-bench container image: {base_container_image}')
+
+ config = AppConfig(
+ default_agent=metadata.agent_class,
+ run_as_openhands=False,
+ max_iterations=metadata.max_iterations,
+ runtime=os.environ.get('RUNTIME', 'eventstream'),
+ sandbox=SandboxConfig(
+ base_container_image=base_container_image,
+ enable_auto_lint=True,
+ use_host_network=False,
+ # large enough timeout, since some testcases take very long to run
+ timeout=300,
+ api_key=os.environ.get('ALLHANDS_API_KEY', None),
+ remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
+ keep_runtime_alive=False,
+ remote_runtime_init_timeout=3600,
+ ),
+ # do not mount workspace
+ workspace_base=None,
+ workspace_mount_path=None,
+ )
+ config.set_llm_config(
+ update_llm_config_for_completions_logging(
+ metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
+ )
+ )
+ agent_config = AgentConfig(
+ codeact_enable_jupyter=False,
+ codeact_enable_browsing=RUN_WITH_BROWSING,
+ codeact_enable_llm_editor=False,
+ )
+ config.set_agent_config(agent_config)
+ return config
+
+
+def initialize_runtime(
+ runtime: Runtime,
+ instance: pd.Series, # this argument is not required
+):
+ """Initialize the runtime for the agent.
+
+ This function is called before the runtime is used to run the agent.
+ """
+ logger.info('-' * 30)
+ logger.info('BEGIN Runtime Initialization Fn')
+ logger.info('-' * 30)
+ workspace_dir_name = _get_commit0_workspace_dir_name(instance)
+ obs: CmdOutputObservation
+
+ action = CmdRunAction(
+ command=f'git clone -b commit0_combined https://github.com/{instance["repo"]}.git'
+ )
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ obs.exit_code == 0,
+ f'Failed to git clone -b commit0_combined https://github.com/{instance["repo"]}.git: {str(obs)}',
+ )
+
+ action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ obs.exit_code == 0,
+ f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
+ )
+
+ action = CmdRunAction(command='git checkout -b openhands')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ obs.exit_code == 0, f'Failed to git checkout new branch openhands: {str(obs)}'
+ )
+
+ # Install commit0
+ action = CmdRunAction(command='/root/.cargo/bin/uv pip install commit0')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ obs.exit_code == 0,
+ f'Failed to install commit0: {str(obs)}',
+ )
+ logger.info('-' * 30)
+ logger.info('END Runtime Initialization Fn')
+ logger.info('-' * 30)
+
+
+def complete_runtime(
+ runtime: Runtime,
+ instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
+) -> dict[str, Any]:
+ """Complete the runtime for the agent.
+
+ This function is called before the runtime is used to run the agent.
+ If you need to do something in the sandbox to get the correctness metric after
+ the agent has run, modify this function.
+ """
+ logger.info('-' * 30)
+ logger.info('BEGIN Runtime Completion Fn')
+ logger.info('-' * 30)
+ obs: CmdOutputObservation
+ workspace_dir_name = _get_commit0_workspace_dir_name(instance)
+
+ action = CmdRunAction(command='git add .')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
+ f'Failed to git add -A: {str(obs)}',
+ )
+
+ action = CmdRunAction(command='git commit -m "openhands edits"')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation)
+ and (obs.exit_code == 0 or obs.exit_code == 1),
+ f'Failed to git commit -m "openhands": {str(obs)}',
+ )
+
+ # Generate diff patch compared to base commit, excluding spec.pdf.bz2 files
+ n_retries = 0
+ git_patch = None
+ while n_retries < 5:
+ action = CmdRunAction(
+ command=f"git diff {instance['base_commit']} HEAD -- . ':(exclude)spec.pdf.bz2'"
+ )
+ action.timeout = 600 + 100 * n_retries
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ n_retries += 1
+ if isinstance(obs, CmdOutputObservation):
+ if obs.exit_code == 0:
+ git_patch = obs.content.strip()
+ break
+ else:
+ logger.info('Failed to get git diff, retrying...')
+ sleep_if_should_continue(10)
+ elif isinstance(obs, ErrorObservation):
+ logger.error(f'Error occurred: {obs.content}. Retrying...')
+ sleep_if_should_continue(10)
+ else:
+ assert_and_raise(False, f'Unexpected observation type: {str(obs)}')
+
+ assert_and_raise(git_patch is not None, 'Failed to get git diff (None)')
+
+ test_dir = instance['test']['test_dir']
+ action = CmdRunAction(
+ command=f"{instance['test']['test_cmd']} --json-report --json-report-file=report.json --continue-on-collection-errors {test_dir} > test_output.txt 2>&1"
+ )
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation),
+ f'Failed to run test command: {str(obs)}',
+ )
+ # Read test output
+ action = CmdRunAction(command='cat test_output.txt')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation),
+ f'Failed to read test output: {str(obs)}',
+ )
+ test_output = obs.content.strip()
+ # logger.info(f'Test output: {test_output}')
+
+ # Save pytest exit code
+ action = CmdRunAction(command='echo $?')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation) and obs.exit_code == 0,
+ f'Failed to save pytest exit code: {str(obs)}',
+ )
+ pytest_exit_code = obs.content.strip()
+ # logger.info(f'Pytest exit code: {pytest_exit_code}')
+
+ # Read the test report
+ action = CmdRunAction(command='cat report.json')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ assert_and_raise(
+ isinstance(obs, CmdOutputObservation),
+ f'Failed to read test report: {str(obs)}',
+ )
+ # Get test IDs from instance
+ repo_name = instance['repo'].split('/')[1]
+ repo_name = repo_name.replace('.', '-')
+ action = CmdRunAction(command=f'commit0 get-tests {repo_name}')
+ action.timeout = 600
+ logger.info(action, extra={'msg_type': 'ACTION'})
+ obs = runtime.run_action(action)
+ # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+ test_ids = obs.content.strip().split('\n')
+
+ try:
+ report = json.loads(obs.content)
+ tests = {x['nodeid']: x['call'] for x in report['tests'] if 'call' in x}
+
+ # Calculate test statistics
+ status = []
+ runtimes = []
+ no_runs = 0
+
+ for test_id in test_ids:
+ if test_id in tests and tests[test_id] is not None:
+ status.append(tests[test_id]['outcome'])
+ runtimes.append(tests[test_id]['duration'])
+ no_runs += 1
+ else:
+ status.append('failed')
+ runtimes.append(0)
+
+ status_counts = Counter(status)
+ total_runtime = sum(runtimes) if no_runs > 0 else 0
+ num_passed = status_counts.get('passed', 0) + status_counts.get('xfail', 0)
+ passed_ratio = num_passed / len(status) if status else 0
+
+ eval_result = {
+ 'name': workspace_dir_name,
+ 'sum': total_runtime,
+ 'passed': passed_ratio,
+ 'num_passed': num_passed,
+ 'num_tests': len(test_ids),
+ }
+
+ except json.JSONDecodeError:
+ logger.error('Failed to parse test report JSON')
+ eval_result = {
+ 'name': workspace_dir_name,
+ 'sum': 0,
+ 'passed': 0,
+ 'num_passed': 0,
+ 'num_tests': len(test_ids),
+ }
+
+ # Create tarball of workspace
+ temp_zip = runtime.copy_from(f'/workspace/{workspace_dir_name}')
+
+ commit0_dir = os.path.dirname(__file__)
+ persistent_zip = os.path.join(commit0_dir, f'{workspace_dir_name}.zip')
+ with open(temp_zip, 'rb') as src, open(persistent_zip, 'wb') as dst:
+ dst.write(src.read())
+ zip_file = persistent_zip
+ return {
+ 'eval_result': eval_result,
+ 'git_patch': git_patch,
+ 'test_output': test_output,
+ 'pytest_exit_code': pytest_exit_code,
+ 'zip_file': zip_file,
+ }
+
+
+def process_instance(
+ instance: pd.Series,
+ metadata: EvalMetadata,
+ reset_logger: bool = True,
+) -> EvalOutput:
+ config = get_config(instance, metadata)
+ # Setup the logger properly, so you can run multi-processing to parallelize the evaluation
+ if reset_logger:
+ log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
+ reset_logger_for_multiprocessing(logger, instance.instance_id, log_dir)
+ else:
+ logger.info(f'Starting evaluation for instance {instance.instance_id}.')
+
+ runtime = create_runtime(config)
+ call_async_from_sync(runtime.connect)
+ try:
+ initialize_runtime(runtime, instance)
+
+ instruction = get_instruction(instance, metadata)
+
+ # Here's how you can run the agent (similar to the `main` function) and get the final task state
+ state: State | None = asyncio.run(
+ run_controller(
+ config=config,
+ initial_user_action=MessageAction(content=instruction),
+ runtime=runtime,
+ fake_user_response_fn=AGENT_CLS_TO_FAKE_USER_RESPONSE_FN[
+ metadata.agent_class
+ ],
+ )
+ )
+
+ # if fatal error, throw EvalError to trigger re-run
+ if (
+ state.last_error
+ and 'fatal error during agent execution' in state.last_error
+ and 'stuck in a loop' not in state.last_error
+ ):
+ raise EvalException('Fatal error detected: ' + state.last_error)
+
+ # ======= THIS IS Commit0 specific =======
+ # Get git patch
+ return_val = complete_runtime(runtime, instance)
+ eval_result = return_val['eval_result']
+ git_patch = return_val['git_patch']
+ test_output = return_val['test_output']
+ pytest_exit_code = return_val['pytest_exit_code']
+ zip_file = return_val['zip_file']
+
+ repo_name = instance['repo'].split('/')[1]
+ zip_dest = os.path.join(
+ metadata.eval_output_dir, 'repos', repo_name, f'{repo_name}.zip'
+ )
+ patch_file = os.path.join(
+ metadata.eval_output_dir, 'repos', repo_name, f'{repo_name}_patch.diff'
+ )
+ test_output_file = os.path.join(
+ metadata.eval_output_dir, 'repos', repo_name, f'{repo_name}_test_output.txt'
+ )
+ pytest_exit_code_file = os.path.join(
+ metadata.eval_output_dir,
+ 'repos',
+ repo_name,
+ f'{repo_name}_pytest_exit_code.txt',
+ )
+
+ os.makedirs(os.path.dirname(zip_dest), exist_ok=True)
+ os.rename(zip_file, zip_dest)
+
+ write_targets = [
+ (patch_file, git_patch),
+ (test_output_file, test_output),
+ (pytest_exit_code_file, pytest_exit_code),
+ ]
+
+ for write_target in write_targets:
+ with open(write_target[0], 'w') as f:
+ f.write(write_target[1])
+
+ logger.info(
+ f'Got evaluation result for repo {instance.instance_id}:\n--------\n{eval_result}\n--------'
+ )
+ finally:
+ runtime.close()
+ # ==========================================
+
+ # ======= Attempt to evaluate the agent's edits =======
+ # we use eval_infer.sh to evaluate the agent's edits, not here
+ # because the agent may alter the environment / testcases
+ test_result = {
+ 'eval_result': eval_result,
+ }
+
+ # If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
+ # You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
+ if state is None:
+ raise ValueError('State should not be None.')
+
+ # NOTE: this is NO LONGER the event stream, but an agent history that includes delegate agent's events
+ histories = [event_to_dict(event) for event in state.history]
+ metrics = state.metrics.get() if state.metrics else None
+
+ # Save the output
+ output = EvalOutput(
+ instance_id=instance.instance_id,
+ instruction=instruction,
+ instance=instance.to_dict(),
+ test_result=test_result,
+ metadata=metadata,
+ history=histories,
+ metrics=metrics,
+ error=state.last_error if state and state.last_error else None,
+ )
+ return output
+
+
+def commit0_setup(dataset: pd.DataFrame, repo_split: str) -> pd.DataFrame:
+ """Setup Commit0 dataset based on split type.
+
+ Args:
+ dataset: Full Commit0 dataset
+ repo_split: Split type ('all', 'lite' or specific repo name)
+
+ Returns:
+ Filtered dataset based on split type
+ """
+
+ filtered_dataset = pd.concat(
+ [
+ dataset[dataset['repo'].str.split('/').str[1] == repo]
+ for repo in SPLIT.get(repo_split, [])
+ ]
+ )
+
+ # Drop setup column if it exists
+ if 'setup' in filtered_dataset.columns:
+ filtered_dataset = filtered_dataset.drop('setup', axis=1)
+
+ # Replace all forward slashes in instance_id with hyphens
+ filtered_dataset['instance_id'] = filtered_dataset['repo'].str.split('/').str[1]
+
+ return filtered_dataset
+
+
+if __name__ == '__main__':
+ parser = get_parser()
+ parser.add_argument(
+ '--dataset',
+ type=str,
+ default='wentingzhao/commit0_combined',
+ help='dataset to evaluate on, only test split exists for this HF dataset',
+ )
+ parser.add_argument(
+ '--split',
+ type=str,
+ default='test',
+ help='this is the HF dataset split',
+ )
+ parser.add_argument(
+ '--repo-split',
+ type=str,
+ default='lite',
+ help='all, lite, or each repo name',
+ )
+ args, _ = parser.parse_known_args()
+
+ # NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
+ # so we don't need to manage file uploading to OpenHands's repo
+ dataset = load_dataset(args.dataset, split=args.split)
+
+ commit0_datasets = commit0_setup(dataset.to_pandas(), args.repo_split)
+
+ logger.info(f'Loaded dataset {args.dataset} with reposplit {args.repo_split}')
+
+ llm_config = None
+ if args.llm_config:
+ llm_config = get_llm_config_arg(args.llm_config)
+ llm_config.log_completions = True
+
+ if llm_config is None:
+ raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
+
+ details = {}
+ _agent_cls = openhands.agenthub.Agent.get_cls(args.agent_cls)
+
+ dataset_descrption = (
+ args.dataset.replace('/', '__') + '-' + args.repo_split.replace('/', '__')
+ )
+ metadata = make_metadata(
+ llm_config,
+ dataset_descrption,
+ args.agent_cls,
+ args.max_iterations,
+ args.eval_note,
+ args.eval_output_dir,
+ details=details,
+ )
+
+ output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
+
+ instances = prepare_dataset(commit0_datasets, output_file, args.eval_n_limit)
+
+ run_evaluation(
+ instances,
+ metadata,
+ output_file,
+ args.eval_num_workers,
+ process_instance,
+ timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
+ )
diff --git a/evaluation/commit0_bench/scripts/cleanup_remote_runtime.sh b/evaluation/commit0_bench/scripts/cleanup_remote_runtime.sh
new file mode 100755
index 000000000000..34685b11aeda
--- /dev/null
+++ b/evaluation/commit0_bench/scripts/cleanup_remote_runtime.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+
+
+# API base URL
+BASE_URL="https://runtime.eval.all-hands.dev"
+
+# Get the list of runtimes
+response=$(curl --silent --location --request GET "${BASE_URL}/list" \
+ --header "X-API-Key: ${ALLHANDS_API_KEY}")
+
+n_runtimes=$(echo $response | jq -r '.total')
+echo "Found ${n_runtimes} runtimes. Stopping them..."
+
+runtime_ids=$(echo $response | jq -r '.runtimes | .[].runtime_id')
+
+# Function to stop a single runtime
+stop_runtime() {
+ local runtime_id=$1
+ local counter=$2
+ echo "Stopping runtime ${counter}/${n_runtimes}: ${runtime_id}"
+ curl --silent --location --request POST "${BASE_URL}/stop" \
+ --header "X-API-Key: ${ALLHANDS_API_KEY}" \
+ --header "Content-Type: application/json" \
+ --data-raw "{\"runtime_id\": \"${runtime_id}\"}"
+ echo
+}
+export -f stop_runtime
+export BASE_URL ALLHANDS_API_KEY n_runtimes
+
+# Use GNU Parallel to stop runtimes in parallel
+echo "$runtime_ids" | parallel -j 16 --progress stop_runtime {} {#}
+
+echo "All runtimes have been stopped."
diff --git a/evaluation/commit0_bench/scripts/run_infer.sh b/evaluation/commit0_bench/scripts/run_infer.sh
new file mode 100755
index 000000000000..d362a096670b
--- /dev/null
+++ b/evaluation/commit0_bench/scripts/run_infer.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+set -eo pipefail
+
+source "evaluation/utils/version_control.sh"
+
+REPO_SPLIT=$1
+MODEL_CONFIG=$2
+COMMIT_HASH=$3
+AGENT=$4
+EVAL_LIMIT=$5
+MAX_ITER=$6
+NUM_WORKERS=$7
+DATASET=$8
+SPLIT=$9
+N_RUNS=${10}
+
+if [ -z "$NUM_WORKERS" ]; then
+ NUM_WORKERS=1
+ echo "Number of workers not specified, use default $NUM_WORKERS"
+fi
+checkout_eval_branch
+
+if [ -z "$AGENT" ]; then
+ echo "Agent not specified, use default CodeActAgent"
+ AGENT="CodeActAgent"
+fi
+
+if [ -z "$MAX_ITER" ]; then
+ echo "MAX_ITER not specified, use default 100"
+ MAX_ITER=100
+fi
+
+if [ -z "$USE_INSTANCE_IMAGE" ]; then
+ echo "USE_INSTANCE_IMAGE not specified, use default true"
+ USE_INSTANCE_IMAGE=true
+fi
+
+if [ -z "$RUN_WITH_BROWSING" ]; then
+ echo "RUN_WITH_BROWSING not specified, use default false"
+ RUN_WITH_BROWSING=false
+fi
+
+
+if [ -z "$DATASET" ]; then
+ echo "DATASET not specified, use default wentingzhao/commit0_combined"
+ DATASET="wentingzhao/commit0_combined"
+fi
+
+if [ -z "$REPO_SPLIT" ]; then
+ echo "REPO_SPLIT not specified, use default lite"
+ REPO_SPLIT=0
+fi
+
+if [ -z "$SPLIT" ]; then
+ echo "HF SPLIT not specified, use default test"
+ SPLIT="test"
+fi
+
+export USE_INSTANCE_IMAGE=$USE_INSTANCE_IMAGE
+echo "USE_INSTANCE_IMAGE: $USE_INSTANCE_IMAGE"
+export RUN_WITH_BROWSING=$RUN_WITH_BROWSING
+echo "RUN_WITH_BROWSING: $RUN_WITH_BROWSING"
+
+get_agent_version
+
+echo "AGENT: $AGENT"
+echo "AGENT_VERSION: $AGENT_VERSION"
+echo "MODEL_CONFIG: $MODEL_CONFIG"
+echo "DATASET: $DATASET"
+echo "HF SPLIT: $SPLIT"
+echo "REPO SPLIT: $REPO_SPLIT"
+
+# Default to NOT use Hint
+if [ -z "$USE_HINT_TEXT" ]; then
+ export USE_HINT_TEXT=false
+fi
+echo "USE_HINT_TEXT: $USE_HINT_TEXT"
+EVAL_NOTE="$AGENT_VERSION"
+# if not using Hint, add -no-hint to the eval note
+if [ "$USE_HINT_TEXT" = false ]; then
+ EVAL_NOTE="$EVAL_NOTE-no-hint"
+fi
+
+if [ "$RUN_WITH_BROWSING" = true ]; then
+ EVAL_NOTE="$EVAL_NOTE-with-browsing"
+fi
+
+if [ -n "$EXP_NAME" ]; then
+ EVAL_NOTE="$EVAL_NOTE-$EXP_NAME"
+fi
+
+function run_eval() {
+ local eval_note=$1
+ COMMAND="poetry run python evaluation/commit0_bench/run_infer.py \
+ --agent-cls $AGENT \
+ --llm-config $MODEL_CONFIG \
+ --max-iterations $MAX_ITER \
+ --eval-num-workers $NUM_WORKERS \
+ --eval-note $eval_note \
+ --dataset $DATASET \
+ --split $SPLIT \
+ --repo-split $REPO_SPLIT"
+
+ if [ -n "$EVAL_LIMIT" ]; then
+ echo "EVAL_LIMIT: $EVAL_LIMIT"
+ COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
+ fi
+
+ # Run the command
+ eval $COMMAND
+}
+
+unset SANDBOX_ENV_GITHUB_TOKEN # prevent the agent from using the github token to push
+if [ -z "$N_RUNS" ]; then
+ N_RUNS=1
+ echo "N_RUNS not specified, use default $N_RUNS"
+fi
+
+for i in $(seq 1 $N_RUNS); do
+ current_eval_note="$EVAL_NOTE-run_$i"
+ echo "EVAL_NOTE: $current_eval_note"
+ run_eval $current_eval_note
+done
+
+checkout_original_branch
diff --git a/frontend/.eslintrc b/frontend/.eslintrc
index d5cb543bd728..29896d083c35 100644
--- a/frontend/.eslintrc
+++ b/frontend/.eslintrc
@@ -10,7 +10,8 @@
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
- "plugin:react-hooks/recommended"
+ "plugin:react-hooks/recommended",
+ "plugin:@tanstack/query/recommended"
],
"plugins": [
"prettier"
diff --git a/frontend/__tests__/clear-session.test.ts b/frontend/__tests__/clear-session.test.ts
deleted file mode 100644
index 4a172608497f..000000000000
--- a/frontend/__tests__/clear-session.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from "vitest";
-import { clearSession } from "../src/utils/clear-session";
-import store from "../src/store";
-import { initialState as browserInitialState } from "../src/state/browserSlice";
-
-describe("clearSession", () => {
- beforeEach(() => {
- // Mock localStorage
- const localStorageMock = {
- getItem: vi.fn(),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- clear: vi.fn(),
- };
- vi.stubGlobal("localStorage", localStorageMock);
-
- // Set initial browser state to non-default values
- store.dispatch({
- type: "browser/setUrl",
- payload: "https://example.com",
- });
- store.dispatch({
- type: "browser/setScreenshotSrc",
- payload: "base64screenshot",
- });
- });
-
- it("should clear localStorage and reset browser state", () => {
- clearSession();
-
- // Verify localStorage items were removed
- expect(localStorage.removeItem).toHaveBeenCalledWith("token");
- expect(localStorage.removeItem).toHaveBeenCalledWith("repo");
-
- // Verify browser state was reset
- const state = store.getState();
- expect(state.browser.url).toBe(browserInitialState.url);
- expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc);
- });
-});
diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx
index 28684401e2cb..f686c45bf9eb 100644
--- a/frontend/__tests__/components/feedback-form.test.tsx
+++ b/frontend/__tests__/components/feedback-form.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/feedback-form";
describe("FeedbackForm", () => {
@@ -12,7 +13,9 @@ describe("FeedbackForm", () => {
});
it("should render correctly", () => {
- render();
+ renderWithProviders(
+ ,
+ );
screen.getByLabelText("Email");
screen.getByLabelText("Private");
@@ -23,7 +26,9 @@ describe("FeedbackForm", () => {
});
it("should switch between private and public permissions", async () => {
- render();
+ renderWithProviders(
+ ,
+ );
const privateRadio = screen.getByLabelText("Private");
const publicRadio = screen.getByLabelText("Public");
@@ -40,10 +45,11 @@ describe("FeedbackForm", () => {
});
it("should call onClose when the close button is clicked", async () => {
- render();
+ renderWithProviders(
+ ,
+ );
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCloseMock).toHaveBeenCalled();
});
-
});
diff --git a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx
index a1c0717783e9..357dd61e1bd9 100644
--- a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx
+++ b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx
@@ -16,16 +16,13 @@ vi.mock("../../services/fileService", async () => ({
}));
const renderFileExplorerWithRunningAgentState = () =>
- renderWithProviders(
- {}} />,
- {
- preloadedState: {
- agent: {
- curAgentState: AgentState.RUNNING,
- },
+ renderWithProviders( {}} />, {
+ preloadedState: {
+ agent: {
+ curAgentState: AgentState.RUNNING,
},
},
- );
+ });
describe.skip("FileExplorer", () => {
afterEach(() => {
diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx
index 6186562ab9f1..b9bb65d0f3c9 100644
--- a/frontend/__tests__/components/user-actions.test.tsx
+++ b/frontend/__tests__/components/user-actions.test.tsx
@@ -1,7 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
-import * as Remix from "@remix-run/react";
import { UserActions } from "#/components/user-actions";
describe("UserActions", () => {
@@ -9,14 +8,9 @@ describe("UserActions", () => {
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
- const useFetcherSpy = vi.spyOn(Remix, "useFetcher");
- // @ts-expect-error - Only returning the relevant properties for the test
- useFetcherSpy.mockReturnValue({ state: "idle" });
-
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
- useFetcherSpy.mockClear();
});
it("should render", () => {
@@ -111,10 +105,8 @@ describe("UserActions", () => {
expect(onLogoutMock).not.toHaveBeenCalled();
});
- it("should display the loading spinner", () => {
- // @ts-expect-error - Only returning the relevant properties for the test
- useFetcherSpy.mockReturnValue({ state: "loading" });
-
+ // FIXME: Spinner now provided through useQuery
+ it.skip("should display the loading spinner", () => {
render(
{
- describe("brand logo", () => {
- it.todo("should not do anything if the user is in the main screen");
- it.todo(
- "should be clickable and redirect to the main screen if the user is not in the main screen",
+ const RemixStub = createRemixStub([{ Component: MainApp, path: "/" }]);
+
+ const { userIsAuthenticatedMock, settingsAreUpToDateMock } = vi.hoisted(
+ () => ({
+ userIsAuthenticatedMock: vi.fn(),
+ settingsAreUpToDateMock: vi.fn(),
+ }),
+ );
+
+ beforeAll(() => {
+ vi.mock("#/utils/user-is-authenticated", () => ({
+ userIsAuthenticated: userIsAuthenticatedMock.mockReturnValue(true),
+ }));
+
+ vi.mock("#/services/settings", async (importOriginal) => ({
+ ...(await importOriginal()),
+ settingsAreUpToDate: settingsAreUpToDateMock,
+ }));
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ it("should render", async () => {
+ renderWithProviders();
+ await screen.findByTestId("root-layout");
+ });
+
+ it("should render the AI config modal if the user is authed", async () => {
+ // Our mock return value is true by default
+ renderWithProviders();
+ await screen.findByTestId("ai-config-modal");
+ });
+
+ it("should render the AI config modal if settings are not up-to-date", async () => {
+ settingsAreUpToDateMock.mockReturnValue(false);
+ renderWithProviders();
+
+ await screen.findByTestId("ai-config-modal");
+ });
+
+ it("should not render the AI config modal if the settings are up-to-date", async () => {
+ settingsAreUpToDateMock.mockReturnValue(true);
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
+ });
+ });
+
+ it("should capture the user's consent", async () => {
+ const user = userEvent.setup();
+ const handleCaptureConsentSpy = vi.spyOn(
+ CaptureConsent,
+ "handleCaptureConsent",
);
+
+ renderWithProviders();
+
+ // The user has not consented to tracking
+ const consentForm = await screen.findByTestId("user-capture-consent-form");
+ expect(handleCaptureConsentSpy).not.toHaveBeenCalled();
+ expect(localStorage.getItem("analytics-consent")).toBeNull();
+
+ const submitButton = within(consentForm).getByRole("button", {
+ name: /confirm preferences/i,
+ });
+ await user.click(submitButton);
+
+ // The user has now consented to tracking
+ expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
+ expect(localStorage.getItem("analytics-consent")).toBe("true");
+ expect(
+ screen.queryByTestId("user-capture-consent-form"),
+ ).not.toBeInTheDocument();
});
- describe("user menu", () => {
- it.todo("should open the user menu when clicked");
+ it("should not render the user consent form if the user has already made a decision", async () => {
+ localStorage.setItem("analytics-consent", "true");
+ renderWithProviders();
- describe("logged out", () => {
- it.todo("should display a placeholder");
- test.todo("the logout option in the user menu should be disabled");
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("user-capture-consent-form"),
+ ).not.toBeInTheDocument();
});
+ });
+
+ it("should render a new project button if a token is set", async () => {
+ localStorage.setItem("token", "test-token");
+ const { rerender } = renderWithProviders();
- describe("logged in", () => {
- it.todo("should display the user's avatar");
- it.todo("should log the user out when the logout option is clicked");
+ await screen.findByTestId("new-project-button");
+
+ localStorage.removeItem("token");
+ rerender();
+
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId("new-project-button"),
+ ).not.toBeInTheDocument();
});
});
- describe("config", () => {
- it.todo("should open the config modal when clicked");
- it.todo(
- "should not save the config and close the config modal when the close button is clicked",
- );
- it.todo(
- "should save the config when the save button is clicked and close the modal",
- );
- it.todo("should warn the user about saving the config when in /app");
+ // TODO: Move to e2e tests
+ it.skip("should update the i18n language when the language settings change", async () => {
+ const changeLanguageSpy = vi.spyOn(i18n, "changeLanguage");
+ const { rerender } = renderWithProviders();
+
+ // The default language is English
+ expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+
+ localStorage.setItem("LANGUAGE", "es");
+
+ rerender();
+ expect(changeLanguageSpy).toHaveBeenCalledWith("es");
+
+ rerender();
+ // The language has not changed, so the spy should not have been called again
+ expect(changeLanguageSpy).toHaveBeenCalledTimes(2);
+ });
+
+ // FIXME: logoutCleanup has been replaced with a hook
+ it.skip("should call logoutCleanup after a logout", async () => {
+ const user = userEvent.setup();
+ localStorage.setItem("ghToken", "test-token");
+
+ // const logoutCleanupSpy = vi.spyOn(LogoutCleanup, "logoutCleanup");
+ renderWithProviders();
+
+ const userActions = await screen.findByTestId("user-actions");
+ const userAvatar = within(userActions).getByTestId("user-avatar");
+ await user.click(userAvatar);
+
+ const logout = within(userActions).getByRole("button", { name: /logout/i });
+ await user.click(logout);
+
+ // expect(logoutCleanupSpy).toHaveBeenCalled();
+ expect(localStorage.getItem("ghToken")).toBeNull();
});
});
diff --git a/frontend/__tests__/utils/cache.test.ts b/frontend/__tests__/utils/cache.test.ts
deleted file mode 100644
index 6b3762c38a65..000000000000
--- a/frontend/__tests__/utils/cache.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { afterEach } from "node:test";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { cache } from "#/utils/cache";
-
-describe("Cache", () => {
- const testKey = "key";
- const testData = { message: "Hello, world!" };
- const testTTL = 1000; // 1 second
-
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it("gets data from memory if not expired", () => {
- cache.set(testKey, testData, testTTL);
-
- expect(cache.get(testKey)).toEqual(testData);
- });
-
- it("should expire after 5 minutes by default", () => {
- cache.set(testKey, testData);
- expect(cache.get(testKey)).not.toBeNull();
-
- vi.advanceTimersByTime(5 * 60 * 1000 + 1);
-
- expect(cache.get(testKey)).toBeNull();
- });
-
- it("returns null if cached data is expired", () => {
- cache.set(testKey, testData, testTTL);
-
- vi.advanceTimersByTime(testTTL + 1);
- expect(cache.get(testKey)).toBeNull();
- });
-
- it("deletes data from memory", () => {
- cache.set(testKey, testData, testTTL);
- cache.delete(testKey);
- expect(cache.get(testKey)).toBeNull();
- });
-
- it("clears all data with the app prefix from memory", () => {
- cache.set(testKey, testData, testTTL);
- cache.set("anotherKey", { data: "More data" }, testTTL);
- cache.clearAll();
- expect(cache.get(testKey)).toBeNull();
- expect(cache.get("anotherKey")).toBeNull();
- });
-});
diff --git a/frontend/__tests__/utils/extract-next-page-from-link.test.ts b/frontend/__tests__/utils/extract-next-page-from-link.test.ts
new file mode 100644
index 000000000000..a7541f95a0ad
--- /dev/null
+++ b/frontend/__tests__/utils/extract-next-page-from-link.test.ts
@@ -0,0 +1,13 @@
+import { expect, test } from "vitest";
+import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
+
+test("extractNextPageFromLink", () => {
+ const link = `; rel="prev", ; rel="next", ; rel="last", ; rel="first"`;
+ expect(extractNextPageFromLink(link)).toBe(4);
+
+ const noNextLink = `; rel="prev", ; rel="first"`;
+ expect(extractNextPageFromLink(noNextLink)).toBeNull();
+
+ const extra = `; rel="next", ; rel="last"`;
+ expect(extractNextPageFromLink(extra)).toBe(2);
+});
diff --git a/frontend/__tests__/utils/handle-capture-consent.test.ts b/frontend/__tests__/utils/handle-capture-consent.test.ts
new file mode 100644
index 000000000000..3b337424a7ae
--- /dev/null
+++ b/frontend/__tests__/utils/handle-capture-consent.test.ts
@@ -0,0 +1,44 @@
+import posthog from "posthog-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { handleCaptureConsent } from "#/utils/handle-capture-consent";
+
+describe("handleCaptureConsent", () => {
+ const optInSpy = vi.spyOn(posthog, "opt_in_capturing");
+ const optOutSpy = vi.spyOn(posthog, "opt_out_capturing");
+ const hasOptedInSpy = vi.spyOn(posthog, "has_opted_in_capturing");
+ const hasOptedOutSpy = vi.spyOn(posthog, "has_opted_out_capturing");
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should opt out of of capturing", () => {
+ handleCaptureConsent(false);
+
+ expect(optOutSpy).toHaveBeenCalled();
+ expect(optInSpy).not.toHaveBeenCalled();
+ });
+
+ it("should opt in to capturing if the user consents", () => {
+ handleCaptureConsent(true);
+
+ expect(optInSpy).toHaveBeenCalled();
+ expect(optOutSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not opt in to capturing if the user is already opted in", () => {
+ hasOptedInSpy.mockReturnValueOnce(true);
+ handleCaptureConsent(true);
+
+ expect(optInSpy).not.toHaveBeenCalled();
+ expect(optOutSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not opt out of capturing if the user is already opted out", () => {
+ hasOptedOutSpy.mockReturnValueOnce(true);
+ handleCaptureConsent(false);
+
+ expect(optOutSpy).not.toHaveBeenCalled();
+ expect(optInSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a5abe9cc6a58..89613585652a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
- "version": "0.14.1",
+ "version": "0.14.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
- "version": "0.14.1",
+ "version": "0.14.2",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
@@ -15,6 +15,7 @@
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
+ "@tanstack/react-query": "^5.60.5",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -50,6 +51,7 @@
"@remix-run/dev": "^2.11.2",
"@remix-run/testing": "^2.11.2",
"@tailwindcss/typography": "^0.5.15",
+ "@tanstack/eslint-plugin-query": "^5.60.1",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
@@ -69,9 +71,9 @@
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
- "eslint-plugin-jsx-a11y": "^6.9.0",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
- "eslint-plugin-react": "^7.35.0",
+ "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
@@ -5812,6 +5814,143 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
}
},
+ "node_modules/@tanstack/eslint-plugin-query": {
+ "version": "5.60.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.60.1.tgz",
+ "integrity": "sha512-oCaWtFKa6WwX14fm/Sp486eTFXXgadiDzEYxhM/tiAlM+xzvPwp6ZHgR6sndmvYK+s/jbksDCTLIPS0PCH8L2g==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/utils": "^8.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ }
+ },
+ "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz",
+ "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz",
+ "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz",
+ "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/visitor-keys": "8.14.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz",
+ "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "8.14.0",
+ "@typescript-eslint/types": "8.14.0",
+ "@typescript-eslint/typescript-estree": "8.14.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ }
+ },
+ "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz",
+ "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.14.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.60.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.5.tgz",
+ "integrity": "sha512-jiS1aC3XI3BJp83ZiTuDLerTmn9P3U95r6p+6/SNauLJaYxfIC4dMuWygwnBHIZxjn2zJqEpj3nysmPieoxfPQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.60.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.60.5.tgz",
+ "integrity": "sha512-M77bOsPwj1wYE56gk7iJvxGAr4IC12NWdIDhT+Eo8ldkWRHMvIR8I/rufIvT1OXoV/bl7EECwuRuMlxxWtvW2Q==",
+ "dependencies": {
+ "@tanstack/query-core": "5.60.5"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -8161,38 +8300,6 @@
"node": ">=6"
}
},
- "node_modules/deep-equal": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
- "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
- "dev": true,
- "dependencies": {
- "array-buffer-byte-length": "^1.0.0",
- "call-bind": "^1.0.5",
- "es-get-iterator": "^1.1.3",
- "get-intrinsic": "^1.2.2",
- "is-arguments": "^1.1.1",
- "is-array-buffer": "^3.0.2",
- "is-date-object": "^1.0.5",
- "is-regex": "^1.1.4",
- "is-shared-array-buffer": "^1.0.2",
- "isarray": "^2.0.5",
- "object-is": "^1.1.5",
- "object-keys": "^1.1.1",
- "object.assign": "^4.1.4",
- "regexp.prototype.flags": "^1.5.1",
- "side-channel": "^1.0.4",
- "which-boxed-primitive": "^1.0.2",
- "which-collection": "^1.0.1",
- "which-typed-array": "^1.1.13"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -8593,26 +8700,6 @@
"node": ">= 0.4"
}
},
- "node_modules/es-get-iterator": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
- "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
- "dev": true,
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "has-symbols": "^1.0.3",
- "is-arguments": "^1.1.1",
- "is-map": "^2.0.2",
- "is-set": "^2.0.2",
- "is-string": "^1.0.7",
- "isarray": "^2.0.5",
- "stop-iteration-iterator": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/es-iterator-helpers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.1.0.tgz",
@@ -9062,12 +9149,12 @@
}
},
"node_modules/eslint-plugin-jsx-a11y": {
- "version": "6.10.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.0.tgz",
- "integrity": "sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==",
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
+ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
"dev": true,
"dependencies": {
- "aria-query": "~5.1.3",
+ "aria-query": "^5.3.2",
"array-includes": "^3.1.8",
"array.prototype.flatmap": "^1.3.2",
"ast-types-flow": "^0.0.8",
@@ -9075,14 +9162,13 @@
"axobject-query": "^4.1.0",
"damerau-levenshtein": "^1.0.8",
"emoji-regex": "^9.2.2",
- "es-iterator-helpers": "^1.0.19",
"hasown": "^2.0.2",
"jsx-ast-utils": "^3.3.5",
"language-tags": "^1.0.9",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.8",
"safe-regex-test": "^1.0.3",
- "string.prototype.includes": "^2.0.0"
+ "string.prototype.includes": "^2.0.1"
},
"engines": {
"node": ">=4.0"
@@ -9092,12 +9178,12 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
- "dependencies": {
- "deep-equal": "^2.0.5"
+ "engines": {
+ "node": ">= 0.4"
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
@@ -9153,9 +9239,9 @@
}
},
"node_modules/eslint-plugin-react": {
- "version": "7.37.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz",
- "integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==",
+ "version": "7.37.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz",
+ "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.8",
@@ -9163,7 +9249,7 @@
"array.prototype.flatmap": "^1.3.2",
"array.prototype.tosorted": "^1.1.4",
"doctrine": "^2.1.0",
- "es-iterator-helpers": "^1.0.19",
+ "es-iterator-helpers": "^1.1.0",
"estraverse": "^5.3.0",
"hasown": "^2.0.2",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
@@ -18934,22 +19020,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/object-is": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
- "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
- "dev": true,
- "dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -22698,18 +22768,6 @@
"integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
"dev": true
},
- "node_modules/stop-iteration-iterator": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
- "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==",
- "dev": true,
- "dependencies": {
- "internal-slot": "^1.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1757adbe8ac3..5b7d375b692f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
- "version": "0.14.1",
+ "version": "0.14.2",
"private": true,
"type": "module",
"engines": {
@@ -14,6 +14,7 @@
"@remix-run/node": "^2.11.2",
"@remix-run/react": "^2.11.2",
"@remix-run/serve": "^2.11.2",
+ "@tanstack/react-query": "^5.60.5",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -76,6 +77,7 @@
"@remix-run/dev": "^2.11.2",
"@remix-run/testing": "^2.11.2",
"@tailwindcss/typography": "^0.5.15",
+ "@tanstack/eslint-plugin-query": "^5.60.1",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
@@ -95,9 +97,9 @@
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
- "eslint-plugin-jsx-a11y": "^6.9.0",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
- "eslint-plugin-react": "^7.35.0",
+ "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 53a48004433d..cfbc10779e14 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -26,7 +26,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: "http://127.0.0.1:3000",
+ baseURL: "http://localhost:3001/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -72,8 +72,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
- command: "npm run dev:mock -- --port 3000",
- url: "http://127.0.0.1:3000",
+ command: "npm run dev:mock -- --port 3001",
+ url: "http://localhost:3001/",
reuseExistingServer: !process.env.CI,
},
});
diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts
index 2a0b5c509254..1cd3c7587cc2 100644
--- a/frontend/src/api/github.ts
+++ b/frontend/src/api/github.ts
@@ -27,82 +27,19 @@ export const isGitHubErrorReponse = >(
*/
export const retrieveGitHubUserRepositories = async (
token: string,
- per_page = 30,
page = 1,
+ per_page = 30,
): Promise => {
const url = new URL("https://api.github.com/user/repos");
url.searchParams.append("sort", "pushed"); // sort by most recently pushed
- url.searchParams.append("per_page", per_page.toString());
url.searchParams.append("page", page.toString());
+ url.searchParams.append("per_page", per_page.toString());
return fetch(url.toString(), {
headers: generateGitHubAPIHeaders(token),
});
};
-/**
- * Given a GitHub token, retrieves all repositories of the authenticated user
- * @param token The GitHub token
- * @returns A list of repositories or an error response
- */
-export const retrieveAllGitHubUserRepositories = async (
- token: string,
-): Promise => {
- const repositories: GitHubRepository[] = [];
-
- // Fetch the first page to extract the last page number and get the first batch of data
- const firstPageResponse = await retrieveGitHubUserRepositories(token, 100, 1);
-
- if (!firstPageResponse.ok) {
- return {
- message: "Failed to fetch repositories",
- documentation_url:
- "https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user",
- status: firstPageResponse.status,
- };
- }
-
- const firstPageData = await firstPageResponse.json();
- repositories.push(...firstPageData);
-
- // Check for pagination and extract the last page number
- const link = firstPageResponse.headers.get("link");
- const lastPageMatch = link?.match(/page=(\d+)>; rel="last"/);
- const lastPage = lastPageMatch ? parseInt(lastPageMatch[1], 10) : 1;
-
- // If there is only one page, return the fetched repositories
- if (lastPage === 1) {
- return repositories;
- }
-
- // Create an array of promises for the remaining pages
- const promises = [];
- for (let page = 2; page <= lastPage; page += 1) {
- promises.push(retrieveGitHubUserRepositories(token, 100, page));
- }
-
- // Fetch all pages in parallel
- const responses = await Promise.all(promises);
-
- for (const response of responses) {
- if (response.ok) {
- // TODO: Is there a way to avoid using await within a loop?
- // eslint-disable-next-line no-await-in-loop
- const data = await response.json();
- repositories.push(...data);
- } else {
- return {
- message: "Failed to fetch repositories",
- documentation_url:
- "https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user",
- status: response.status,
- };
- }
- }
-
- return repositories;
-};
-
/**
* Given a GitHub token, retrieves the authenticated user
* @param token The GitHub token
@@ -114,6 +51,11 @@ export const retrieveGitHubUser = async (
const response = await fetch("https://api.github.com/user", {
headers: generateGitHubAPIHeaders(token),
});
+
+ if (!response.ok) {
+ throw new Error("Failed to retrieve user data");
+ }
+
const data = await response.json();
if (!isGitHubErrorReponse(data)) {
@@ -149,5 +91,9 @@ export const retrieveLatestGitHubCommit = async (
headers: generateGitHubAPIHeaders(token),
});
+ if (!response.ok) {
+ throw new Error("Failed to retrieve latest commit");
+ }
+
return response.json();
};
diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts
index 33f08d94f21e..20e7843befca 100644
--- a/frontend/src/api/open-hands.ts
+++ b/frontend/src/api/open-hands.ts
@@ -1,5 +1,4 @@
import { request } from "#/services/api";
-import { cache } from "#/utils/cache";
import {
SaveFileSuccessResponse,
FileUploadSuccessResponse,
@@ -17,13 +16,13 @@ class OpenHands {
* @returns List of models available
*/
static async getModels(): Promise {
- const cachedData = cache.get("models");
- if (cachedData) return cachedData;
+ const response = await fetch("/api/options/models");
- const data = await request("/api/options/models");
- cache.set("models", data);
+ if (!response.ok) {
+ throw new Error("Failed to fetch models");
+ }
- return data;
+ return response.json();
}
/**
@@ -31,13 +30,13 @@ class OpenHands {
* @returns List of agents available
*/
static async getAgents(): Promise {
- const cachedData = cache.get("agents");
- if (cachedData) return cachedData;
+ const response = await fetch("/api/options/agents");
- const data = await request(`/api/options/agents`);
- cache.set("agents", data);
+ if (!response.ok) {
+ throw new Error("Failed to fetch agents");
+ }
- return data;
+ return response.json();
}
/**
@@ -45,23 +44,23 @@ class OpenHands {
* @returns List of security analyzers available
*/
static async getSecurityAnalyzers(): Promise {
- const cachedData = cache.get("agents");
- if (cachedData) return cachedData;
+ const response = await fetch("/api/options/security-analyzers");
- const data = await request(`/api/options/security-analyzers`);
- cache.set("security-analyzers", data);
+ if (!response.ok) {
+ throw new Error("Failed to fetch security analyzers");
+ }
- return data;
+ return response.json();
}
static async getConfig(): Promise {
- const cachedData = cache.get("config");
- if (cachedData) return cachedData;
+ const response = await fetch("/config.json");
- const data = await request("/config.json");
- cache.set("config", data);
+ if (!response.ok) {
+ throw new Error("Failed to fetch config");
+ }
- return data;
+ return response.json();
}
/**
@@ -69,10 +68,21 @@ class OpenHands {
* @param path Path to list files from
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
*/
- static async getFiles(path?: string): Promise {
- let url = "/api/list-files";
- if (path) url += `?path=${encodeURIComponent(path)}`;
- return request(url);
+ static async getFiles(token: string, path?: string): Promise {
+ const url = new URL("/api/list-files", window.location.origin);
+ if (path) url.searchParams.append("path", path);
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch files");
+ }
+
+ return response.json();
}
/**
@@ -80,9 +90,21 @@ class OpenHands {
* @param path Full path of the file to retrieve
* @returns Content of the file
*/
- static async getFile(path: string): Promise {
- const url = `/api/select-file?file=${encodeURIComponent(path)}`;
- const data = await request(url);
+ static async getFile(token: string, path: string): Promise {
+ const url = new URL("/api/select-file", window.location.origin);
+ url.searchParams.append("file", path);
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch file");
+ }
+
+ const data = await response.json();
return data.code;
}
@@ -93,16 +115,32 @@ class OpenHands {
* @returns Success message or error message
*/
static async saveFile(
+ token: string,
path: string,
content: string,
- ): Promise {
- return request(`/api/save-file`, {
+ ): Promise {
+ const response = await fetch("/api/save-file", {
method: "POST",
body: JSON.stringify({ filePath: path, content }),
headers: {
"Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
},
});
+
+ if (!response.ok) {
+ throw new Error("Failed to save file");
+ }
+
+ const data = (await response.json()) as
+ | SaveFileSuccessResponse
+ | ErrorResponse;
+
+ if ("error" in data) {
+ throw new Error(data.error);
+ }
+
+ return data;
}
/**
@@ -111,24 +149,33 @@ class OpenHands {
* @returns Success message or error message
*/
static async uploadFiles(
- file: File[],
- ): Promise {
+ token: string,
+ files: File[],
+ ): Promise {
const formData = new FormData();
- file.forEach((f) => formData.append("files", f));
+ files.forEach((file) => formData.append("files", file));
- return request(`/api/upload-files`, {
+ const response = await fetch("/api/upload-files", {
method: "POST",
body: formData,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
});
- }
- /**
- * Get the blob of the workspace zip
- * @returns Blob of the workspace zip
- */
- static async getWorkspaceZip(): Promise {
- const response = await request(`/api/zip-directory`, {}, false, true);
- return response.blob();
+ if (!response.ok) {
+ throw new Error("Failed to upload files");
+ }
+
+ const data = (await response.json()) as
+ | FileUploadSuccessResponse
+ | ErrorResponse;
+
+ if ("error" in data) {
+ throw new Error(data.error);
+ }
+
+ return data;
}
/**
@@ -136,14 +183,53 @@ class OpenHands {
* @param data Feedback data
* @returns The stored feedback data
*/
- static async submitFeedback(data: Feedback): Promise {
- return request(`/api/submit-feedback`, {
+ static async submitFeedback(
+ token: string,
+ feedback: Feedback,
+ ): Promise {
+ const response = await fetch("/api/submit-feedback", {
method: "POST",
- body: JSON.stringify(data),
+ body: JSON.stringify(feedback),
headers: {
"Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to submit feedback");
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Authenticate with GitHub token
+ * @returns Response with authentication status and user info if successful
+ */
+ static async authenticate(
+ gitHubToken: string,
+ appMode: GetConfigResponse["APP_MODE"],
+ ): Promise {
+ if (appMode === "oss") return true;
+
+ const response = await fetch("/api/authenticate", {
+ method: "POST",
+ headers: {
+ "X-GitHub-Token": gitHubToken,
},
});
+
+ return response.ok;
+ }
+
+ /**
+ * Get the blob of the workspace zip
+ * @returns Blob of the workspace zip
+ */
+ static async getWorkspaceZip(): Promise {
+ const response = await request(`/api/zip-directory`, {}, false, true);
+ return response.blob();
}
/**
@@ -153,27 +239,19 @@ class OpenHands {
static async getGitHubAccessToken(
code: string,
): Promise {
- return request(`/api/github/callback`, {
+ const response = await fetch("/api/github/callback", {
method: "POST",
body: JSON.stringify({ code }),
headers: {
"Content-Type": "application/json",
},
});
- }
- /**
- * Authenticate with GitHub token
- * @returns Response with authentication status and user info if successful
- */
- static async authenticate(): Promise {
- return request(
- `/api/authenticate`,
- {
- method: "POST",
- },
- true,
- );
+ if (!response.ok) {
+ throw new Error("Failed to get GitHub access token");
+ }
+
+ return response.json();
}
/**
diff --git a/frontend/src/components/analytics-consent-form-modal.tsx b/frontend/src/components/analytics-consent-form-modal.tsx
index e122b9e8a9bf..b5ea03810f4c 100644
--- a/frontend/src/components/analytics-consent-form-modal.tsx
+++ b/frontend/src/components/analytics-consent-form-modal.tsx
@@ -1,4 +1,3 @@
-import { useFetcher } from "@remix-run/react";
import { ModalBackdrop } from "./modals/modal-backdrop";
import ModalBody from "./modals/ModalBody";
import ModalButton from "./buttons/ModalButton";
@@ -6,15 +5,31 @@ import {
BaseModalTitle,
BaseModalDescription,
} from "./modals/confirmation-modals/BaseModal";
+import { handleCaptureConsent } from "#/utils/handle-capture-consent";
-export function AnalyticsConsentFormModal() {
- const fetcher = useFetcher({ key: "set-consent" });
+interface AnalyticsConsentFormModalProps {
+ onClose: () => void;
+}
+
+export function AnalyticsConsentFormModal({
+ onClose,
+}: AnalyticsConsentFormModalProps) {
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const formData = new FormData(e.currentTarget);
+ const analytics = formData.get("analytics") === "on";
+
+ handleCaptureConsent(analytics);
+ localStorage.setItem("analytics-consent", analytics.toString());
+
+ onClose();
+ };
return (
-
@@ -36,7 +51,7 @@ export function AnalyticsConsentFormModal() {
className="bg-primary text-white w-full hover:opacity-80"
/>
-
+
);
}
diff --git a/frontend/src/components/chat-interface.tsx b/frontend/src/components/chat-interface.tsx
index f0004bd749d4..b53c668c0c8f 100644
--- a/frontend/src/components/chat-interface.tsx
+++ b/frontend/src/components/chat-interface.tsx
@@ -1,7 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
-import { useRouteLoaderData } from "@remix-run/react";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
@@ -27,22 +26,22 @@ import {
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import OpenHands from "#/api/open-hands";
-import { clientLoader } from "#/routes/_oh";
import { downloadWorkspace } from "#/utils/download-workspace";
import { SuggestionItem } from "./suggestion-item";
+import { useAuth } from "#/context/auth-context";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
+ const { gitHubToken } = useAuth();
const { send, status, isLoadingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
- const rootLoaderData = useRouteLoaderData("routes/_oh");
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -175,7 +174,7 @@ export function ChatInterface() {
{(curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED) && (
- {rootLoaderData?.ghToken ? (
+ {gitHubToken ? (
void;
@@ -19,22 +18,21 @@ export function Controls({
showSecurityLock,
lastCommitData,
}: ControlsProps) {
- const rootData = useRouteLoaderData("routes/_oh");
- const appData = useRouteLoaderData("routes/_oh.app");
+ const { gitHubToken } = useAuth();
+ const { selectedRepository } = useSelector(
+ (state: RootState) => state.initalQuery,
+ );
const projectMenuCardData = React.useMemo(
() =>
- rootData?.user &&
- !isGitHubErrorReponse(rootData.user) &&
- appData?.repo &&
- lastCommitData
+ selectedRepository && lastCommitData
? {
- avatar: rootData.user.avatar_url,
- repoName: appData.repo,
+ repoName: selectedRepository,
lastCommit: lastCommitData,
+ avatar: null, // TODO: fetch repo avatar
}
: null,
- [rootData, appData, lastCommitData],
+ [selectedRepository, lastCommitData],
);
return (
@@ -55,7 +53,7 @@ export function Controls({
diff --git a/frontend/src/components/event-handler.tsx b/frontend/src/components/event-handler.tsx
index 930bbafc840f..014035ed2998 100644
--- a/frontend/src/components/event-handler.tsx
+++ b/frontend/src/components/event-handler.tsx
@@ -1,12 +1,6 @@
import React from "react";
-import {
- useFetcher,
- useLoaderData,
- useRouteLoaderData,
-} from "@remix-run/react";
import { useDispatch, useSelector } from "react-redux";
import toast from "react-hot-toast";
-
import posthog from "posthog-js";
import {
useWsClient,
@@ -24,17 +18,18 @@ import {
clearSelectedRepository,
setImportedProjectZip,
} from "#/state/initial-query-slice";
-import { clientLoader as appClientLoader } from "#/routes/_oh.app";
import store, { RootState } from "#/store";
import { createChatMessage } from "#/services/chatService";
-import { clientLoader as rootClientLoader } from "#/routes/_oh";
import { isGitHubErrorReponse } from "#/api/github";
-import OpenHands from "#/api/open-hands";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { setCurrentAgentState } from "#/state/agentSlice";
import AgentState from "#/types/AgentState";
-import { getSettings } from "#/services/settings";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
+import { useGitHubUser } from "#/hooks/query/use-github-user";
+import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
+import { useAuth } from "#/context/auth-context";
+import { useEndSession } from "#/hooks/use-end-session";
+import { useUserPrefs } from "#/context/user-prefs-context";
interface ServerError {
error: boolean | string;
@@ -48,41 +43,48 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
"observation" in data && data.observation === "error";
export function EventHandler({ children }: React.PropsWithChildren) {
+ const { setToken, gitHubToken } = useAuth();
+ const { settings } = useUserPrefs();
const { events, status, send } = useWsClient();
const statusRef = React.useRef(null);
const runtimeActive = status === WsClientProviderStatus.ACTIVE;
- const fetcher = useFetcher();
const dispatch = useDispatch();
const { files, importedProjectZip, initialQuery } = useSelector(
(state: RootState) => state.initalQuery,
);
- const { ghToken, repo } = useLoaderData();
+ const endSession = useEndSession();
+
+ // FIXME: Bad practice - should be handled with state
+ const { selectedRepository } = useSelector(
+ (state: RootState) => state.initalQuery,
+ );
+
+ const { data: user } = useGitHubUser();
+ const { mutate: uploadFiles } = useUploadFiles();
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
- const data = useRouteLoaderData("routes/_oh");
const userId = React.useMemo(() => {
- if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id;
+ if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
- }, [data?.user]);
- const userSettings = getSettings();
+ }, [user]);
React.useEffect(() => {
if (!events.length) {
return;
}
const event = events[events.length - 1];
- if (event.token) {
- fetcher.submit({ token: event.token as string }, { method: "post" });
+ if (event.token && typeof event.token === "string") {
+ setToken(event.token);
return;
}
if (isServerError(event)) {
if (event.error_code === 401) {
toast.error("Session expired.");
- fetcher.submit({}, { method: "POST", action: "/end-session" });
+ endSession();
return;
}
@@ -120,9 +122,9 @@ export function EventHandler({ children }: React.PropsWithChildren) {
if (status === WsClientProviderStatus.ACTIVE) {
let additionalInfo = "";
- if (ghToken && repo) {
- send(getCloneRepoCommand(ghToken, repo));
- additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`;
+ if (gitHubToken && selectedRepository) {
+ send(getCloneRepoCommand(gitHubToken, selectedRepository));
+ additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`;
dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'?
}
// if there's an uploaded project zip, add it to the chat
@@ -157,35 +159,35 @@ export function EventHandler({ children }: React.PropsWithChildren) {
}, [status]);
React.useEffect(() => {
- if (runtimeActive && userId && ghToken) {
+ if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
- send(getGitHubTokenCommand(ghToken));
+ send(getGitHubTokenCommand(gitHubToken));
}
- }, [userId, ghToken, runtimeActive]);
+ }, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
- (async () => {
- if (runtimeActive && importedProjectZip) {
- // upload files action
- try {
- const blob = base64ToBlob(importedProjectZip);
- const file = new File([blob], "imported-project.zip", {
- type: blob.type,
- });
- await OpenHands.uploadFiles([file]);
- dispatch(setImportedProjectZip(null));
- } catch (error) {
- toast.error("Failed to upload project files.");
- }
- }
- })();
+ if (runtimeActive && importedProjectZip) {
+ const blob = base64ToBlob(importedProjectZip);
+ const file = new File([blob], "imported-project.zip", {
+ type: blob.type,
+ });
+ uploadFiles(
+ { files: [file] },
+ {
+ onError: () => {
+ toast.error("Failed to upload project files.");
+ },
+ },
+ );
+ dispatch(setImportedProjectZip(null));
+ }
}, [runtimeActive, importedProjectZip]);
React.useEffect(() => {
- if (userSettings.LLM_API_KEY) {
+ if (settings.LLM_API_KEY) {
posthog.capture("user_activated");
}
- }, [userSettings.LLM_API_KEY]);
+ }, [settings.LLM_API_KEY]);
return children;
}
diff --git a/frontend/src/components/feedback-form.tsx b/frontend/src/components/feedback-form.tsx
index 078e4b0ccca6..bc68de9bffc3 100644
--- a/frontend/src/components/feedback-form.tsx
+++ b/frontend/src/components/feedback-form.tsx
@@ -2,7 +2,7 @@ import React from "react";
import hotToast from "react-hot-toast";
import ModalButton from "./buttons/ModalButton";
import { Feedback } from "#/api/open-hands.types";
-import OpenHands from "#/api/open-hands";
+import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
@@ -13,8 +13,6 @@ interface FeedbackFormProps {
}
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
- const [isSubmitting, setIsSubmitting] = React.useState(false);
-
const copiedToClipboardToast = () => {
hotToast("Password copied to clipboard", {
icon: "📋",
@@ -53,10 +51,11 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
);
};
+ const { mutate: submitFeedback, isPending } = useSubmitFeedback();
+
const handleSubmit = async (event: React.FormEvent) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
- setIsSubmitting(true);
const email = formData.get("email")?.toString() || "";
const permissions = (formData.get("permissions")?.toString() ||
@@ -71,11 +70,17 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
token: "",
};
- const response = await OpenHands.submitFeedback(feedback);
- const { message, feedback_id, password } = response.body; // eslint-disable-line
- const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
- shareFeedbackToast(message, link, password);
- setIsSubmitting(false);
+ submitFeedback(
+ { feedback },
+ {
+ onSuccess: (data) => {
+ const { message, feedback_id, password } = data.body; // eslint-disable-line
+ const link = `${VIEWER_PAGE}?share_id=${feedback_id}`;
+ shareFeedbackToast(message, link, password);
+ onClose();
+ },
+ },
+ );
};
return (
@@ -109,13 +114,13 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
void;
@@ -95,13 +94,9 @@ function ExplorerActions({
interface FileExplorerProps {
isOpen: boolean;
onToggle: () => void;
- error: string | null;
}
-function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
- const { revalidate } = useRevalidator();
-
- const { paths, setPaths } = useFiles();
+function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -112,64 +107,59 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
fileInputRef.current?.click(); // Trigger the file browser
};
- const refreshWorkspace = () => {
- if (
- curAgentState === AgentState.LOADING ||
- curAgentState === AgentState.STOPPED
- ) {
- return;
- }
- dispatch(setRefreshID(Math.random()));
- OpenHands.getFiles().then(setPaths);
- revalidate();
- };
+ const { data: paths, refetch, error } = useListFiles();
- const uploadFileData = async (files: FileList) => {
- try {
- const result = await OpenHands.uploadFiles(Array.from(files));
+ const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
+ const uploadedCount = data.uploaded_files.length;
+ const skippedCount = data.skipped_files.length;
- if (isOpenHandsErrorResponse(result)) {
- // Handle error response
- toast.error(
- `upload-error-${new Date().getTime()}`,
- result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
- );
- return;
- }
+ if (uploadedCount > 0) {
+ toast.success(
+ `upload-success-${new Date().getTime()}`,
+ t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
+ count: uploadedCount,
+ }),
+ );
+ }
- const uploadedCount = result.uploaded_files.length;
- const skippedCount = result.skipped_files.length;
+ if (skippedCount > 0) {
+ const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
+ count: skippedCount,
+ });
+ toast.info(message);
+ }
- if (uploadedCount > 0) {
- toast.success(
- `upload-success-${new Date().getTime()}`,
- t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
- count: uploadedCount,
- }),
- );
- }
+ if (uploadedCount === 0 && skippedCount === 0) {
+ toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
+ }
+ };
- if (skippedCount > 0) {
- const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
- count: skippedCount,
- });
- toast.info(message);
- }
+ const handleUploadError = (e: Error) => {
+ toast.error(
+ `upload-error-${new Date().getTime()}`,
+ e.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
+ );
+ };
- if (uploadedCount === 0 && skippedCount === 0) {
- toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
- }
+ const { mutate: uploadFiles } = useUploadFiles();
- refreshWorkspace();
- } catch (e) {
- // Handle unexpected errors (network issues, etc.)
- toast.error(
- `upload-error-${new Date().getTime()}`,
- t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
- );
+ const refreshWorkspace = () => {
+ if (
+ curAgentState !== AgentState.LOADING &&
+ curAgentState !== AgentState.STOPPED
+ ) {
+ refetch();
}
};
+ const uploadFileData = (files: FileList) => {
+ uploadFiles(
+ { files: Array.from(files) },
+ { onSuccess: handleUploadSuccess, onError: handleUploadError },
+ );
+ refreshWorkspace();
+ };
+
const handleVSCodeClick = async (e: React.MouseEvent) => {
e.preventDefault();
try {
@@ -265,13 +255,13 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) {
{!error && (
)}
{error && (
-
{error}
+
{error.message}
)}
{isOpen && (
diff --git a/frontend/src/components/file-explorer/TreeNode.tsx b/frontend/src/components/file-explorer/TreeNode.tsx
index b3aa3c28335c..d65eb07148ad 100644
--- a/frontend/src/components/file-explorer/TreeNode.tsx
+++ b/frontend/src/components/file-explorer/TreeNode.tsx
@@ -1,12 +1,10 @@
import React from "react";
-import { useSelector } from "react-redux";
-import toast from "react-hot-toast";
-import { RootState } from "#/store";
import FolderIcon from "../FolderIcon";
import FileIcon from "../FileIcons";
-import OpenHands from "#/api/open-hands";
import { useFiles } from "#/context/files";
import { cn } from "#/utils/utils";
+import { useListFiles } from "#/hooks/query/use-list-files";
+import { useListFile } from "#/hooks/query/use-list-file";
interface TitleProps {
name: string;
@@ -44,51 +42,35 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
selectedPath,
} = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
- const [children, setChildren] = React.useState(null);
- const refreshID = useSelector((state: RootState) => state.code.refreshID);
-
- const fileParts = path.split("/");
- const filename =
- fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
const isDirectory = path.endsWith("/");
- const refreshChildren = async () => {
- if (!isDirectory || !isOpen) {
- setChildren(null);
- return;
- }
+ const { data: paths } = useListFiles({
+ path,
+ enabled: isDirectory && isOpen,
+ });
- try {
- const newChildren = await OpenHands.getFiles(path);
- setChildren(newChildren);
- } catch (error) {
- toast.error("Failed to fetch files");
- }
- };
+ const { data: fileContent, refetch } = useListFile({ path });
React.useEffect(() => {
- (async () => {
- await refreshChildren();
- })();
- }, [refreshID, isOpen]);
-
- const handleClick = async () => {
- if (isDirectory) {
- setIsOpen((prev) => !prev);
- } else {
+ if (fileContent) {
const code = modifiedFiles[path] || files[path];
-
- try {
- const fetchedCode = await OpenHands.getFile(path);
- setSelectedPath(path);
- if (!code || fetchedCode !== files[path]) {
- setFileContent(path, fetchedCode);
- }
- } catch (error) {
- toast.error("Failed to fetch file");
+ if (!code || fileContent !== files[path]) {
+ setFileContent(path, fileContent);
}
}
+ }, [fileContent, path]);
+
+ const fileParts = path.split("/");
+ const filename =
+ fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
+
+ const handleClick = async () => {
+ if (isDirectory) setIsOpen((prev) => !prev);
+ else {
+ setSelectedPath(path);
+ await refetch();
+ }
};
return (
@@ -116,9 +98,9 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
)}
- {isOpen && children && (
+ {isOpen && paths && (
- {children.map((child, index) => (
+ {paths.map((child, index) => (
))}
diff --git a/frontend/src/components/form/settings-form.tsx b/frontend/src/components/form/settings-form.tsx
index 02a042e8a5c4..ec235a2a780e 100644
--- a/frontend/src/components/form/settings-form.tsx
+++ b/frontend/src/components/form/settings-form.tsx
@@ -4,19 +4,26 @@ import {
Input,
Switch,
} from "@nextui-org/react";
-import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
+import { useLocation } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import React from "react";
+import posthog from "posthog-js";
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
-import { Settings } from "#/services/settings";
+import { getDefaultSettings, Settings } from "#/services/settings";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
-import { clientAction } from "#/routes/settings";
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
import ModalButton from "../buttons/ModalButton";
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
+import {
+ extractSettings,
+ saveSettingsView,
+ updateSettingsVersion,
+} from "#/utils/settings-utils";
+import { useEndSession } from "#/hooks/use-end-session";
+import { useUserPrefs } from "#/context/user-prefs-context";
interface SettingsFormProps {
disabled?: boolean;
@@ -35,19 +42,36 @@ export function SettingsForm({
securityAnalyzers,
onClose,
}: SettingsFormProps) {
+ const { saveSettings } = useUserPrefs();
+ const endSession = useEndSession();
+
const location = useLocation();
- const navigate = useNavigate();
const { t } = useTranslation();
- const fetcher = useFetcher();
const formRef = React.useRef(null);
- React.useEffect(() => {
- if (fetcher.data?.success) {
- navigate("/");
+ const resetOngoingSession = () => {
+ if (location.pathname.startsWith("/app")) {
+ endSession();
onClose();
}
- }, [fetcher.data, navigate, onClose]);
+ };
+
+ const handleFormSubmission = (formData: FormData) => {
+ const keys = Array.from(formData.keys());
+ const isUsingAdvancedOptions = keys.includes("use-advanced-options");
+ const newSettings = extractSettings(formData);
+
+ saveSettings(newSettings);
+ saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
+ updateSettingsVersion();
+ resetOngoingSession();
+
+ posthog.capture("settings_saved", {
+ LLM_MODEL: newSettings.LLM_MODEL,
+ LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET",
+ });
+ };
const advancedAlreadyInUse = React.useMemo(() => {
if (models.length > 0) {
@@ -83,20 +107,17 @@ export function SettingsForm({
React.useState(false);
const [showWarningModal, setShowWarningModal] = React.useState(false);
- const submitForm = (formData: FormData) => {
- if (location.pathname === "/app") formData.set("end-session", "true");
- fetcher.submit(formData, { method: "POST", action: "/settings" });
- };
-
const handleConfirmResetSettings = () => {
- const formData = new FormData(formRef.current ?? undefined);
- formData.set("intent", "reset");
- submitForm(formData);
+ saveSettings(getDefaultSettings());
+ resetOngoingSession();
+ posthog.capture("settings_reset");
+
+ onClose();
};
const handleConfirmEndSession = () => {
const formData = new FormData(formRef.current ?? undefined);
- submitForm(formData);
+ handleFormSubmission(formData);
};
const handleSubmit = (event: React.FormEvent) => {
@@ -106,10 +127,11 @@ export function SettingsForm({
if (!apiKey) {
setShowWarningModal(true);
- } else if (location.pathname === "/app") {
+ } else if (location.pathname.startsWith("/app")) {
setConfirmEndSessionModalOpen(true);
} else {
- submitForm(formData);
+ handleFormSubmission(formData);
+ onClose();
}
};
@@ -117,18 +139,15 @@ export function SettingsForm({
const formData = new FormData(formRef.current ?? undefined);
const apiKey = formData.get("api-key");
- if (!apiKey) {
- setShowWarningModal(true);
- } else {
- onClose();
- }
+ if (!apiKey) setShowWarningModal(true);
+ else onClose();
};
const handleWarningConfirm = () => {
setShowWarningModal(false);
const formData = new FormData(formRef.current ?? undefined);
formData.set("api-key", ""); // Set null value for API key
- submitForm(formData);
+ handleFormSubmission(formData);
onClose();
};
@@ -138,11 +157,9 @@ export function SettingsForm({
return (
-
@@ -267,9 +284,7 @@ export function SettingsForm({
aria-label="Agent"
data-testid="agent-input"
name="agent"
- defaultSelectedKey={
- fetcher.formData?.get("agent")?.toString() ?? settings.AGENT
- }
+ defaultSelectedKey={settings.AGENT}
isClearable={false}
inputProps={{
classNames: {
@@ -302,10 +317,7 @@ export function SettingsForm({
id="security-analyzer"
name="security-analyzer"
aria-label="Security Analyzer"
- defaultSelectedKey={
- fetcher.formData?.get("security-analyzer")?.toString() ??
- settings.SECURITY_ANALYZER
- }
+ defaultSelectedKey={settings.SECURITY_ANALYZER}
inputProps={{
classNames: {
inputWrapper:
@@ -346,7 +358,7 @@ export function SettingsForm({
-
+
{confirmResetDefaultsModalOpen && (
diff --git a/frontend/src/components/github-repositories-suggestion-box.tsx b/frontend/src/components/github-repositories-suggestion-box.tsx
index 4886513dd487..b00a48c65749 100644
--- a/frontend/src/components/github-repositories-suggestion-box.tsx
+++ b/frontend/src/components/github-repositories-suggestion-box.tsx
@@ -1,8 +1,5 @@
import React from "react";
-import {
- isGitHubErrorReponse,
- retrieveAllGitHubUserRepositories,
-} from "#/api/github";
+import { isGitHubErrorReponse } from "#/api/github";
import { SuggestionBox } from "#/routes/_oh._index/suggestion-box";
import { ConnectToGitHubModal } from "./modals/connect-to-github-modal";
import { ModalBackdrop } from "./modals/modal-backdrop";
@@ -12,9 +9,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
- repositories: Awaited<
- ReturnType
- > | null;
+ repositories: GitHubRepository[];
gitHubAuthUrl: string | null;
user: GitHubErrorReponse | GitHubUser | null;
}
@@ -57,7 +52,7 @@ export function GitHubRepositoriesSuggestionBox({
isLoggedIn ? (
) : (
void;
@@ -28,41 +27,33 @@ function AccountSettingsModal({
gitHubError,
analyticsConsent,
}: AccountSettingsModalProps) {
+ const { gitHubToken, setGitHubToken, logout } = useAuth();
+ const { saveSettings } = useUserPrefs();
const { t } = useTranslation();
- const data = useRouteLoaderData("routes/_oh");
- const settingsFetcher = useFetcher({
- key: "settings",
- });
- const loginFetcher = useFetcher({ key: "login" });
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
- const language = formData.get("language")?.toString();
+
const ghToken = formData.get("ghToken")?.toString();
+ const language = formData.get("language")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
- const accountForm = new FormData();
- const loginForm = new FormData();
+ if (ghToken) setGitHubToken(ghToken);
- accountForm.append("intent", "account");
+ // The form returns the language label, so we need to find the corresponding
+ // language key to save it in the settings
if (language) {
const languageKey = AvailableLanguages.find(
({ label }) => label === language,
)?.value;
- accountForm.append("language", languageKey ?? "en");
+
+ if (languageKey) saveSettings({ LANGUAGE: languageKey });
}
- if (ghToken) loginForm.append("ghToken", ghToken);
- accountForm.append("analytics", analytics.toString());
- settingsFetcher.submit(accountForm, {
- method: "POST",
- action: "/settings",
- });
- loginFetcher.submit(loginForm, {
- method: "POST",
- action: "/login",
- });
+ handleCaptureConsent(analytics);
+ const ANALYTICS = analytics.toString();
+ localStorage.setItem("analytics-consent", ANALYTICS);
onClose();
};
@@ -88,7 +79,7 @@ function AccountSettingsModal({
name="ghToken"
label="GitHub Token"
type="password"
- defaultValue={data?.ghToken ?? ""}
+ defaultValue={gitHubToken ?? ""}
/>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
@@ -106,15 +97,12 @@ function AccountSettingsModal({
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
)}
- {data?.ghToken && !gitHubError && (
+ {gitHubToken && !gitHubError && (
{
- settingsFetcher.submit(
- {},
- { method: "POST", action: "/logout" },
- );
+ logout();
onClose();
}}
className="text-danger self-start"
@@ -133,10 +121,6 @@ function AccountSettingsModal({
-
-
-
- );
-}
-
-export default ConnectToGitHubByTokenModal;
diff --git a/frontend/src/components/modals/connect-to-github-modal.tsx b/frontend/src/components/modals/connect-to-github-modal.tsx
index 19cc4ac36ff4..bd0e6b764bef 100644
--- a/frontend/src/components/modals/connect-to-github-modal.tsx
+++ b/frontend/src/components/modals/connect-to-github-modal.tsx
@@ -1,4 +1,3 @@
-import { useFetcher, useRouteLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import ModalBody from "./ModalBody";
import { CustomInput } from "../form/custom-input";
@@ -7,19 +6,26 @@ import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/BaseModal";
-import { clientLoader } from "#/routes/_oh";
-import { clientAction } from "#/routes/login";
import { I18nKey } from "#/i18n/declaration";
+import { useAuth } from "#/context/auth-context";
interface ConnectToGitHubModalProps {
onClose: () => void;
}
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
- const data = useRouteLoaderData("routes/_oh");
- const fetcher = useFetcher({ key: "login" });
+ const { gitHubToken, setGitHubToken } = useAuth();
const { t } = useTranslation();
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+ const ghToken = formData.get("ghToken")?.toString();
+
+ if (ghToken) setGitHubToken(ghToken);
+ onClose();
+ };
+
return (
@@ -40,18 +46,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
}
/>
-
+
+
);
}
diff --git a/frontend/src/components/project-menu/ProjectMenuCard.tsx b/frontend/src/components/project-menu/ProjectMenuCard.tsx
index 1a32c2f802d1..a840732cea04 100644
--- a/frontend/src/components/project-menu/ProjectMenuCard.tsx
+++ b/frontend/src/components/project-menu/ProjectMenuCard.tsx
@@ -17,7 +17,7 @@ import { useWsClient } from "#/context/ws-client-provider";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
githubData: {
- avatar: string;
+ avatar: string | null;
repoName: string;
lastCommit: GitHubCommit;
} | null;
diff --git a/frontend/src/components/project-menu/project-menu-details.tsx b/frontend/src/components/project-menu/project-menu-details.tsx
index 6b5382a43689..8bb67a2ec8ba 100644
--- a/frontend/src/components/project-menu/project-menu-details.tsx
+++ b/frontend/src/components/project-menu/project-menu-details.tsx
@@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsProps {
repoName: string;
- avatar: string;
+ avatar: string | null;
lastCommit: GitHubCommit;
}
@@ -23,7 +23,7 @@ export function ProjectMenuDetails({
rel="noreferrer noopener"
className="flex items-center gap-2"
>
-
+ {avatar &&
}
{repoName}
diff --git a/frontend/src/components/user-actions.tsx b/frontend/src/components/user-actions.tsx
index d605cb895d96..c3bfc4bd02e4 100644
--- a/frontend/src/components/user-actions.tsx
+++ b/frontend/src/components/user-actions.tsx
@@ -1,5 +1,4 @@
import React from "react";
-import { useFetcher } from "@remix-run/react";
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
import { UserAvatar } from "./user-avatar";
@@ -14,8 +13,6 @@ export function UserActions({
onLogout,
user,
}: UserActionsProps) {
- const loginFetcher = useFetcher({ key: "login" });
-
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
@@ -39,11 +36,7 @@ export function UserActions({
return (
-
+
{accountContextMenuIsVisible && (
void;
+ setGitHubToken: (token: string | null) => void;
+ clearToken: () => void;
+ clearGitHubToken: () => void;
+ logout: () => void;
+}
+
+const AuthContext = React.createContext(undefined);
+
+function AuthProvider({ children }: React.PropsWithChildren) {
+ const [tokenState, setTokenState] = React.useState(() =>
+ localStorage.getItem("token"),
+ );
+ const [gitHubTokenState, setGitHubTokenState] = React.useState(
+ () => localStorage.getItem("ghToken"),
+ );
+
+ React.useLayoutEffect(() => {
+ setTokenState(localStorage.getItem("token"));
+ setGitHubTokenState(localStorage.getItem("ghToken"));
+ });
+
+ const setToken = (token: string | null) => {
+ setTokenState(token);
+
+ if (token) localStorage.setItem("token", token);
+ else localStorage.removeItem("token");
+ };
+
+ const setGitHubToken = (token: string | null) => {
+ setGitHubTokenState(token);
+
+ if (token) localStorage.setItem("ghToken", token);
+ else localStorage.removeItem("ghToken");
+ };
+
+ const clearToken = () => {
+ setTokenState(null);
+ localStorage.removeItem("token");
+ };
+
+ const clearGitHubToken = () => {
+ setGitHubTokenState(null);
+ localStorage.removeItem("ghToken");
+ };
+
+ const logout = () => {
+ clearGitHubToken();
+ posthog.reset();
+ };
+
+ const value = React.useMemo(
+ () => ({
+ token: tokenState,
+ gitHubToken: gitHubTokenState,
+ setToken,
+ setGitHubToken,
+ clearToken,
+ clearGitHubToken,
+ logout,
+ }),
+ [tokenState, gitHubTokenState],
+ );
+
+ return {children};
+}
+
+function useAuth() {
+ const context = React.useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error("useAuth must be used within a AuthProvider");
+ }
+ return context;
+}
+
+export { AuthProvider, useAuth };
diff --git a/frontend/src/context/user-prefs-context.tsx b/frontend/src/context/user-prefs-context.tsx
new file mode 100644
index 000000000000..e3573c9234c0
--- /dev/null
+++ b/frontend/src/context/user-prefs-context.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import {
+ getSettings,
+ Settings,
+ saveSettings as updateAndSaveSettingsToLocalStorage,
+ settingsAreUpToDate as checkIfSettingsAreUpToDate,
+} from "#/services/settings";
+
+interface UserPrefsContextType {
+ settings: Settings;
+ settingsAreUpToDate: boolean;
+ saveSettings: (settings: Partial) => void;
+}
+
+const UserPrefsContext = React.createContext(
+ undefined,
+);
+
+function UserPrefsProvider({ children }: React.PropsWithChildren) {
+ const [settings, setSettings] = React.useState(getSettings());
+ const [settingsAreUpToDate, setSettingsAreUpToDate] = React.useState(
+ checkIfSettingsAreUpToDate(),
+ );
+
+ const saveSettings = (newSettings: Partial) => {
+ updateAndSaveSettingsToLocalStorage(newSettings);
+ setSettings(getSettings());
+ setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
+ };
+
+ const value = React.useMemo(
+ () => ({
+ settings,
+ settingsAreUpToDate,
+ saveSettings,
+ }),
+ [settings, settingsAreUpToDate],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+function useUserPrefs() {
+ const context = React.useContext(UserPrefsContext);
+ if (context === undefined) {
+ throw new Error("useUserPrefs must be used within a UserPrefsProvider");
+ }
+ return context;
+}
+
+export { UserPrefsProvider, useUserPrefs };
diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx
index cb8f3d16f0a9..875daf565d4c 100644
--- a/frontend/src/entry.client.tsx
+++ b/frontend/src/entry.client.tsx
@@ -11,26 +11,23 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import store from "./store";
-import OpenHands from "./api/open-hands";
+import { useConfig } from "./hooks/query/use-config";
+import { AuthProvider } from "./context/auth-context";
+import { UserPrefsProvider } from "./context/user-prefs-context";
function PosthogInit() {
- const [key, setKey] = React.useState(null);
+ const { data: config } = useConfig();
React.useEffect(() => {
- OpenHands.getConfig().then((config) => {
- setKey(config.POSTHOG_CLIENT_KEY);
- });
- }, []);
-
- React.useEffect(() => {
- if (key) {
- posthog.init(key, {
+ if (config?.POSTHOG_CLIENT_KEY) {
+ posthog.init(config.POSTHOG_CLIENT_KEY, {
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
});
}
- }, [key]);
+ }, [config]);
return null;
}
@@ -48,14 +45,22 @@ async function prepareApp() {
}
}
+const queryClient = new QueryClient();
+
prepareApp().then(() =>
startTransition(() => {
hydrateRoot(
document,
-
-
+
+
+
+
+
+
+
+
,
);
diff --git a/frontend/src/hooks/mutation/use-save-file.ts b/frontend/src/hooks/mutation/use-save-file.ts
new file mode 100644
index 000000000000..30edcda21a15
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-save-file.ts
@@ -0,0 +1,21 @@
+import { useMutation } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+
+type SaveFileArgs = {
+ path: string;
+ content: string;
+};
+
+export const useSaveFile = () => {
+ const { token } = useAuth();
+
+ return useMutation({
+ mutationFn: ({ path, content }: SaveFileArgs) =>
+ OpenHands.saveFile(token || "", path, content),
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-submit-feedback.ts b/frontend/src/hooks/mutation/use-submit-feedback.ts
new file mode 100644
index 000000000000..0253b69d559e
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-submit-feedback.ts
@@ -0,0 +1,21 @@
+import { useMutation } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import { Feedback } from "#/api/open-hands.types";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+
+type SubmitFeedbackArgs = {
+ feedback: Feedback;
+};
+
+export const useSubmitFeedback = () => {
+ const { token } = useAuth();
+
+ return useMutation({
+ mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
+ OpenHands.submitFeedback(token || "", feedback),
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-upload-files.ts b/frontend/src/hooks/mutation/use-upload-files.ts
new file mode 100644
index 000000000000..0f7a31ed3811
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-upload-files.ts
@@ -0,0 +1,16 @@
+import { useMutation } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+
+type UploadFilesArgs = {
+ files: File[];
+};
+
+export const useUploadFiles = () => {
+ const { token } = useAuth();
+
+ return useMutation({
+ mutationFn: ({ files }: UploadFilesArgs) =>
+ OpenHands.uploadFiles(token || "", files),
+ });
+};
diff --git a/frontend/src/hooks/query/use-ai-config-options.ts b/frontend/src/hooks/query/use-ai-config-options.ts
new file mode 100644
index 000000000000..9e63cf6a8275
--- /dev/null
+++ b/frontend/src/hooks/query/use-ai-config-options.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+const fetchAiConfigOptions = async () => ({
+ models: await OpenHands.getModels(),
+ agents: await OpenHands.getAgents(),
+ securityAnalyzers: await OpenHands.getSecurityAnalyzers(),
+});
+
+export const useAIConfigOptions = () =>
+ useQuery({
+ queryKey: ["ai-config-options"],
+ queryFn: fetchAiConfigOptions,
+ });
diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts
new file mode 100644
index 000000000000..8b81af13b537
--- /dev/null
+++ b/frontend/src/hooks/query/use-config.ts
@@ -0,0 +1,8 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+
+export const useConfig = () =>
+ useQuery({
+ queryKey: ["config"],
+ queryFn: OpenHands.getConfig,
+ });
diff --git a/frontend/src/hooks/query/use-github-user.ts b/frontend/src/hooks/query/use-github-user.ts
new file mode 100644
index 000000000000..ac0de4acce63
--- /dev/null
+++ b/frontend/src/hooks/query/use-github-user.ts
@@ -0,0 +1,40 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import posthog from "posthog-js";
+import { retrieveGitHubUser, isGitHubErrorReponse } from "#/api/github";
+import { useAuth } from "#/context/auth-context";
+import { useConfig } from "./use-config";
+
+export const useGitHubUser = () => {
+ const { gitHubToken } = useAuth();
+ const { data: config } = useConfig();
+
+ const user = useQuery({
+ queryKey: ["user", gitHubToken],
+ queryFn: async () => {
+ const data = await retrieveGitHubUser(gitHubToken!);
+
+ if (isGitHubErrorReponse(data)) {
+ throw new Error("Failed to retrieve user data");
+ }
+
+ return data;
+ },
+ enabled: !!gitHubToken && !!config?.APP_MODE,
+ retry: false,
+ });
+
+ React.useEffect(() => {
+ if (user.data) {
+ posthog.identify(user.data.login, {
+ company: user.data.company,
+ name: user.data.name,
+ email: user.data.email,
+ user: user.data.login,
+ mode: config?.APP_MODE || "oss",
+ });
+ }
+ }, [user.data]);
+
+ return user;
+};
diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts
new file mode 100644
index 000000000000..9f6971b754b8
--- /dev/null
+++ b/frontend/src/hooks/query/use-is-authed.ts
@@ -0,0 +1,19 @@
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+import OpenHands from "#/api/open-hands";
+import { useConfig } from "./use-config";
+import { useAuth } from "#/context/auth-context";
+
+export const useIsAuthed = () => {
+ const { gitHubToken } = useAuth();
+ const { data: config } = useConfig();
+
+ const appMode = React.useMemo(() => config?.APP_MODE, [config]);
+
+ return useQuery({
+ queryKey: ["user", "authenticated", gitHubToken, appMode],
+ queryFn: () => OpenHands.authenticate(gitHubToken || "", appMode!),
+ enabled: !!appMode,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ });
+};
diff --git a/frontend/src/hooks/query/use-latest-repo-commit.ts b/frontend/src/hooks/query/use-latest-repo-commit.ts
new file mode 100644
index 000000000000..3ead53c6c57d
--- /dev/null
+++ b/frontend/src/hooks/query/use-latest-repo-commit.ts
@@ -0,0 +1,28 @@
+import { useQuery } from "@tanstack/react-query";
+import { retrieveLatestGitHubCommit, isGitHubErrorReponse } from "#/api/github";
+import { useAuth } from "#/context/auth-context";
+
+interface UseLatestRepoCommitConfig {
+ repository: string | null;
+}
+
+export const useLatestRepoCommit = (config: UseLatestRepoCommitConfig) => {
+ const { gitHubToken } = useAuth();
+
+ return useQuery({
+ queryKey: ["latest_commit", gitHubToken, config.repository],
+ queryFn: async () => {
+ const data = await retrieveLatestGitHubCommit(
+ gitHubToken!,
+ config.repository!,
+ );
+
+ if (isGitHubErrorReponse(data)) {
+ throw new Error("Failed to retrieve latest commit");
+ }
+
+ return data[0];
+ },
+ enabled: !!gitHubToken && !!config.repository,
+ });
+};
diff --git a/frontend/src/hooks/query/use-list-file.ts b/frontend/src/hooks/query/use-list-file.ts
new file mode 100644
index 000000000000..074bf6b72963
--- /dev/null
+++ b/frontend/src/hooks/query/use-list-file.ts
@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+
+interface UseListFileConfig {
+ path: string;
+}
+
+export const useListFile = (config: UseListFileConfig) => {
+ const { token } = useAuth();
+
+ return useQuery({
+ queryKey: ["file", token, config.path],
+ queryFn: () => OpenHands.getFile(token || "", config.path),
+ enabled: false, // don't fetch by default, trigger manually via `refetch`
+ });
+};
diff --git a/frontend/src/hooks/query/use-list-files.ts b/frontend/src/hooks/query/use-list-files.ts
new file mode 100644
index 000000000000..7baa395fd7be
--- /dev/null
+++ b/frontend/src/hooks/query/use-list-files.ts
@@ -0,0 +1,24 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ useWsClient,
+ WsClientProviderStatus,
+} from "#/context/ws-client-provider";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+
+interface UseListFilesConfig {
+ path?: string;
+ enabled?: boolean;
+}
+
+export const useListFiles = (config?: UseListFilesConfig) => {
+ const { token } = useAuth();
+ const { status } = useWsClient();
+ const isActive = status === WsClientProviderStatus.ACTIVE;
+
+ return useQuery({
+ queryKey: ["files", token, config?.path],
+ queryFn: () => OpenHands.getFiles(token!, config?.path),
+ enabled: isActive && config?.enabled && !!token,
+ });
+};
diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts
new file mode 100644
index 000000000000..8b97d6bcd7d8
--- /dev/null
+++ b/frontend/src/hooks/query/use-user-repositories.ts
@@ -0,0 +1,63 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import React from "react";
+import {
+ isGitHubErrorReponse,
+ retrieveGitHubUserRepositories,
+} from "#/api/github";
+import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
+import { useAuth } from "#/context/auth-context";
+
+interface UserRepositoriesQueryFnProps {
+ pageParam: number;
+ ghToken: string;
+}
+
+const userRepositoriesQueryFn = async ({
+ pageParam,
+ ghToken,
+}: UserRepositoriesQueryFnProps) => {
+ const response = await retrieveGitHubUserRepositories(
+ ghToken,
+ pageParam,
+ 100,
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch repositories");
+ }
+
+ const data = (await response.json()) as GitHubRepository | GitHubErrorReponse;
+
+ if (isGitHubErrorReponse(data)) {
+ throw new Error(data.message);
+ }
+
+ const link = response.headers.get("link") ?? "";
+ const nextPage = extractNextPageFromLink(link);
+
+ return { data, nextPage };
+};
+
+export const useUserRepositories = () => {
+ const { gitHubToken } = useAuth();
+
+ const repos = useInfiniteQuery({
+ queryKey: ["repositories", gitHubToken],
+ queryFn: async ({ pageParam }) =>
+ userRepositoriesQueryFn({ pageParam, ghToken: gitHubToken! }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => lastPage.nextPage,
+ enabled: !!gitHubToken,
+ });
+
+ // TODO: Once we create our custom dropdown component, we should fetch data onEndReached
+ // (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
+ const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
+ React.useEffect(() => {
+ if (!isFetchingNextPage && isSuccess && hasNextPage) {
+ fetchNextPage();
+ }
+ }, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
+
+ return repos;
+};
diff --git a/frontend/src/hooks/use-end-session.ts b/frontend/src/hooks/use-end-session.ts
new file mode 100644
index 000000000000..602bcfa6779e
--- /dev/null
+++ b/frontend/src/hooks/use-end-session.ts
@@ -0,0 +1,31 @@
+import { useDispatch } from "react-redux";
+import { useNavigate } from "@remix-run/react";
+import { useAuth } from "#/context/auth-context";
+import {
+ initialState as browserInitialState,
+ setScreenshotSrc,
+ setUrl,
+} from "#/state/browserSlice";
+import { clearSelectedRepository } from "#/state/initial-query-slice";
+
+export const useEndSession = () => {
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const { clearToken } = useAuth();
+
+ /**
+ * End the current session by clearing the token and redirecting to the home page.
+ */
+ const endSession = () => {
+ clearToken();
+ dispatch(clearSelectedRepository());
+
+ // Reset browser state to initial values
+ dispatch(setUrl(browserInitialState.url));
+ dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
+
+ navigate("/");
+ };
+
+ return endSession;
+};
diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts
new file mode 100644
index 000000000000..e9d493764c0e
--- /dev/null
+++ b/frontend/src/hooks/use-github-auth-url.ts
@@ -0,0 +1,20 @@
+import React from "react";
+import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
+import { GetConfigResponse } from "#/api/open-hands.types";
+
+interface UseGitHubAuthUrlConfig {
+ gitHubToken: string | null;
+ appMode: GetConfigResponse["APP_MODE"] | null;
+ gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
+}
+
+export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
+ React.useMemo(() => {
+ if (config.appMode === "saas" && !config.gitHubToken)
+ return generateGitHubAuthUrl(
+ config.gitHubClientId || "",
+ new URL(window.location.href),
+ );
+
+ return null;
+ }, [config.gitHubToken, config.appMode, config.gitHubClientId]);
diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx
index d73ee7fd455d..10e47ba0f57a 100644
--- a/frontend/src/routes/_oh._index/route.tsx
+++ b/frontend/src/routes/_oh._index/route.tsx
@@ -1,81 +1,40 @@
-import {
- Await,
- ClientActionFunctionArgs,
- ClientLoaderFunctionArgs,
- defer,
- redirect,
- useLoaderData,
- useRouteLoaderData,
-} from "@remix-run/react";
+import { useLocation, useNavigate } from "@remix-run/react";
import React from "react";
import { useDispatch } from "react-redux";
-import posthog from "posthog-js";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
-import { retrieveAllGitHubUserRepositories } from "#/api/github";
-import store from "#/store";
-import {
- setImportedProjectZip,
- setInitialQuery,
-} from "#/state/initial-query-slice";
-import { clientLoader as rootClientLoader } from "#/routes/_oh";
-import OpenHands from "#/api/open-hands";
-import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
+import { setImportedProjectZip } from "#/state/initial-query-slice";
import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
+import { useUserRepositories } from "#/hooks/query/use-user-repositories";
+import { useGitHubUser } from "#/hooks/query/use-github-user";
+import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
+import { useConfig } from "#/hooks/query/use-config";
+import { useAuth } from "#/context/auth-context";
-export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
- let isSaas = false;
- let githubClientId: string | null = null;
-
- try {
- const config = await OpenHands.getConfig();
- isSaas = config.APP_MODE === "saas";
- githubClientId = config.GITHUB_CLIENT_ID;
- } catch (error) {
- isSaas = false;
- githubClientId = null;
- }
-
- const ghToken = localStorage.getItem("ghToken");
- const token = localStorage.getItem("token");
- if (token) return redirect("/app");
-
- let repositories: ReturnType<
- typeof retrieveAllGitHubUserRepositories
- > | null = null;
- if (ghToken) {
- const data = retrieveAllGitHubUserRepositories(ghToken);
- repositories = data;
- }
+function Home() {
+ const { token, gitHubToken } = useAuth();
- let githubAuthUrl: string | null = null;
- if (isSaas && githubClientId) {
- const requestUrl = new URL(request.url);
- githubAuthUrl = generateGitHubAuthUrl(githubClientId, requestUrl);
- }
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const navigate = useNavigate();
- return defer({ repositories, githubAuthUrl });
-};
+ const formRef = React.useRef(null);
-export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
- const formData = await request.formData();
- const q = formData.get("q")?.toString();
- if (q) store.dispatch(setInitialQuery(q));
+ const { data: config } = useConfig();
+ const { data: user } = useGitHubUser();
+ const { data: repositories } = useUserRepositories();
- posthog.capture("initial_query_submitted", {
- query_character_length: q?.length,
+ const gitHubAuthUrl = useGitHubAuthUrl({
+ gitHubToken,
+ appMode: config?.APP_MODE || null,
+ gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});
- return redirect("/app");
-};
-
-function Home() {
- const dispatch = useDispatch();
- const rootData = useRouteLoaderData("routes/_oh");
- const { repositories, githubAuthUrl } = useLoaderData();
- const formRef = React.useRef(null);
+ React.useEffect(() => {
+ if (token) navigate("/app");
+ }, [location.pathname]);
return (
-
+
formRef.current?.requestSubmit()}
+ repositories={
+ repositories?.pages.flatMap((page) => page.data) || []
}
- >
-
- {(resolvedRepositories) => (
- formRef.current?.requestSubmit()}
- repositories={resolvedRepositories}
- gitHubAuthUrl={githubAuthUrl}
- user={rootData?.user || null}
- />
- )}
-
-
+ gitHubAuthUrl={gitHubAuthUrl}
+ user={user || null}
+ // onEndReached={}
+ />
((_, ref) => {
const dispatch = useDispatch();
const navigation = useNavigation();
+ const navigate = useNavigate();
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initalQuery,
@@ -51,13 +57,26 @@ export const TaskForm = React.forwardRef((_, ref) => {
return "What do you want to build?";
}, [selectedRepository]);
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+
+ const q = formData.get("q")?.toString();
+ if (q) dispatch(setInitialQuery(q));
+
+ posthog.capture("initial_query_submitted", {
+ query_character_length: q?.length,
+ });
+
+ navigate("/app");
+ };
+
return (
-
-
+
{
const promises = uploadedFiles.map(convertImageToBase64);
diff --git a/frontend/src/routes/_oh.app._index/code-editor-component.tsx b/frontend/src/routes/_oh.app._index/code-editor-component.tsx
index b9f82befa43f..673010b8b1d9 100644
--- a/frontend/src/routes/_oh.app._index/code-editor-component.tsx
+++ b/frontend/src/routes/_oh.app._index/code-editor-component.tsx
@@ -2,10 +2,9 @@ import { Editor, EditorProps } from "@monaco-editor/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
-import toast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import { useFiles } from "#/context/files";
-import OpenHands from "#/api/open-hands";
+import { useSaveFile } from "#/hooks/mutation/use-save-file";
interface CodeEditorComponentProps {
onMount: EditorProps["onMount"];
@@ -25,6 +24,8 @@ function CodeEditorComponent({
saveFileContent: saveNewFileContent,
} = useFiles();
+ const { mutate: saveFile } = useSaveFile();
+
const handleEditorChange = (value: string | undefined) => {
if (selectedPath && value) modifyFileContent(selectedPath, value);
};
@@ -39,11 +40,7 @@ function CodeEditorComponent({
const content = saveNewFileContent(selectedPath);
if (content) {
- try {
- await OpenHands.saveFile(selectedPath, content);
- } catch (error) {
- toast.error("Failed to save file");
- }
+ saveFile({ path: selectedPath, content });
}
}
};
@@ -66,34 +63,42 @@ function CodeEditorComponent({
);
}
- const fileContent = modifiedFiles[selectedPath] || files[selectedPath];
+ const fileContent: string | undefined =
+ modifiedFiles[selectedPath] || files[selectedPath];
- if (isBase64Image(fileContent)) {
- return (
-
-
-
- );
- }
+ if (fileContent) {
+ if (isBase64Image(fileContent)) {
+ return (
+
+
+
+ );
+ }
- if (isPDF(fileContent)) {
- return (
-
- );
- }
+ if (isPDF(fileContent)) {
+ return (
+
+ );
+ }
- if (isVideo(fileContent)) {
- return (
-
- );
+ if (isVideo(fileContent)) {
+ return (
+
+ );
+ }
}
+
return (
{
- const token = localStorage.getItem("token");
- return json({ token });
-};
-
export function ErrorBoundary() {
const error = useRouteError();
@@ -41,17 +35,18 @@ export function ErrorBoundary() {
}
function CodeEditor() {
- const { curAgentState } = useSelector((state: RootState) => state.agent);
const {
- setPaths,
selectedPath,
modifiedFiles,
saveFileContent: saveNewFileContent,
discardChanges,
} = useFiles();
+
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
const editorRef = React.useRef(null);
+ const { mutate: saveFile } = useSaveFile();
+
const toggleFileExplorer = () => {
setFileExplorerIsOpen((prev) => !prev);
editorRef.current?.layout({ width: 0, height: 0 });
@@ -71,24 +66,10 @@ function CodeEditor() {
monaco.editor.setTheme("oh-dark");
};
- const [errors, setErrors] = React.useState<{ getFiles: string | null }>({
- getFiles: null,
- });
-
const agentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
- React.useEffect(() => {
- if (curAgentState === AgentState.INIT) {
- OpenHands.getFiles()
- .then(setPaths)
- .catch(() => {
- setErrors({ getFiles: "Failed to retrieve files" });
- });
- }
- }, [curAgentState]);
-
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
const isEditingAllowed = React.useMemo(
() =>
@@ -102,12 +83,8 @@ function CodeEditor() {
if (selectedPath) {
const content = modifiedFiles[selectedPath];
if (content) {
- try {
- await OpenHands.saveFile(selectedPath, content);
- saveNewFileContent(selectedPath);
- } catch (error) {
- toast.error("Failed to save file");
- }
+ saveFile({ path: selectedPath, content });
+ saveNewFileContent(selectedPath);
}
}
};
@@ -122,11 +99,7 @@ function CodeEditor() {
return (
-
+
{selectedPath && !isAssetFileType && (
diff --git a/frontend/src/routes/_oh.app.tsx b/frontend/src/routes/_oh.app.tsx
index 664b6124af41..89bf549879a1 100644
--- a/frontend/src/routes/_oh.app.tsx
+++ b/frontend/src/routes/_oh.app.tsx
@@ -1,16 +1,10 @@
import { useDisclosure } from "@nextui-org/react";
import React from "react";
-import {
- Outlet,
- useLoaderData,
- json,
- ClientActionFunctionArgs,
-} from "@remix-run/react";
-import { useDispatch } from "react-redux";
-import { getSettings } from "#/services/settings";
+import { Outlet } from "@remix-run/react";
+import { useDispatch, useSelector } from "react-redux";
import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls";
-import store from "#/store";
+import { RootState } from "#/store";
import { Container } from "#/components/container";
import { clearMessages } from "#/state/chatSlice";
import { clearTerminal } from "#/state/commandSlice";
@@ -18,64 +12,32 @@ import { useEffectOnce } from "#/utils/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ListIcon from "#/icons/list-type-number.svg?react";
-import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
import { clearJupyter } from "#/state/jupyterSlice";
import { FilesProvider } from "#/context/files";
import { ChatInterface } from "#/components/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "#/components/event-handler";
+import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
+import { useAuth } from "#/context/auth-context";
+import { useUserPrefs } from "#/context/user-prefs-context";
-export const clientLoader = async () => {
- const ghToken = localStorage.getItem("ghToken");
- const repo =
- store.getState().initalQuery.selectedRepository ||
- localStorage.getItem("repo");
-
- const settings = getSettings();
- const token = localStorage.getItem("token");
+function App() {
+ const { token, gitHubToken } = useAuth();
+ const { settings } = useUserPrefs();
- if (repo) localStorage.setItem("repo", repo);
+ const dispatch = useDispatch();
- let lastCommit: GitHubCommit | null = null;
- if (ghToken && repo) {
- const data = await retrieveLatestGitHubCommit(ghToken, repo);
- if (isGitHubErrorReponse(data)) {
- // TODO: Handle error
- console.error("Failed to retrieve latest commit", data);
- } else {
- [lastCommit] = data;
- }
- }
+ const { selectedRepository } = useSelector(
+ (state: RootState) => state.initalQuery,
+ );
- return json({
- settings,
- token,
- ghToken,
- repo,
- lastCommit,
+ const { data: latestGitHubCommit } = useLatestRepoCommit({
+ repository: selectedRepository,
});
-};
-
-export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
- const formData = await request.formData();
-
- const token = formData.get("token")?.toString();
- const ghToken = formData.get("ghToken")?.toString();
-
- if (token) localStorage.setItem("token", token);
- if (ghToken) localStorage.setItem("ghToken", ghToken);
-
- return json(null);
-};
-
-function App() {
- const dispatch = useDispatch();
- const { settings, token, ghToken, lastCommit } =
- useLoaderData();
const secrets = React.useMemo(
- () => [ghToken, token].filter((secret) => secret !== null),
- [ghToken, token],
+ () => [gitHubToken, token].filter((secret) => secret !== null),
+ [gitHubToken, token],
);
const Terminal = React.useMemo(
@@ -99,7 +61,7 @@ function App() {
@@ -141,7 +103,7 @@ function App() {
{
- try {
- const config = await OpenHands.getConfig();
- window.__APP_MODE__ = config.APP_MODE;
- window.__GITHUB_CLIENT_ID__ = config.GITHUB_CLIENT_ID;
- } catch (error) {
- window.__APP_MODE__ = "oss";
- window.__GITHUB_CLIENT_ID__ = null;
- }
-
- let token = localStorage.getItem("token");
- const ghToken = localStorage.getItem("ghToken");
- const analyticsConsent = localStorage.getItem("analytics-consent");
- const userConsents = analyticsConsent === "true";
-
- if (!userConsents) {
- posthog.opt_out_capturing();
- } else if (userConsents && !posthog.has_opted_in_capturing()) {
- posthog.opt_in_capturing();
- }
-
- let isAuthed = false;
- let githubAuthUrl: string | null = null;
- let user: GitHubUser | GitHubErrorReponse | null = null;
- try {
- isAuthed = await userIsAuthenticated();
- if (!isAuthed && window.__GITHUB_CLIENT_ID__) {
- const requestUrl = new URL(request.url);
- githubAuthUrl = generateGitHubAuthUrl(
- window.__GITHUB_CLIENT_ID__,
- requestUrl,
- );
- }
- } catch (error) {
- isAuthed = false;
- githubAuthUrl = null;
- }
-
- if (ghToken) user = await retrieveGitHubUser(ghToken);
-
- const settings = getSettings();
- await i18n.changeLanguage(settings.LANGUAGE);
-
- const settingsIsUpdated = settingsAreUpToDate();
- if (!settingsIsUpdated) {
- localStorage.removeItem("token");
- token = null;
- }
-
- // Store the results in cache
- return defer({
- token,
- ghToken,
- isAuthed,
- githubAuthUrl,
- user,
- settingsIsUpdated,
- settings,
- analyticsConsent,
- });
-};
+import { useConfig } from "#/hooks/query/use-config";
+import { useGitHubUser } from "#/hooks/query/use-github-user";
+import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
+import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
+import { useIsAuthed } from "#/hooks/query/use-is-authed";
+import { useAuth } from "#/context/auth-context";
+import { useEndSession } from "#/hooks/use-end-session";
+import { useUserPrefs } from "#/context/user-prefs-context";
export function ErrorBoundary() {
const error = useRouteError();
@@ -127,107 +62,70 @@ export function ErrorBoundary() {
);
}
-type SettingsFormData = {
- models: string[];
- agents: string[];
- securityAnalyzers: string[];
-};
-
export default function MainApp() {
- const navigation = useNavigation();
+ const { token, gitHubToken, clearToken, logout } = useAuth();
+ const { settings, settingsAreUpToDate } = useUserPrefs();
+
const location = useLocation();
- const {
- token,
- ghToken,
- user,
- isAuthed,
- githubAuthUrl,
- settingsIsUpdated,
- settings,
- analyticsConsent,
- } = useLoaderData();
- const logoutFetcher = useFetcher({ key: "logout" });
- const endSessionFetcher = useFetcher({ key: "end-session" });
const dispatch = useDispatch();
+ const endSession = useEndSession();
+
+ // FIXME: Bad practice to use localStorage directly
+ const analyticsConsent = localStorage.getItem("analytics-consent");
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
- const [settingsFormData, setSettingsFormData] =
- React.useState({
- models: [],
- agents: [],
- securityAnalyzers: [],
- });
- const [settingsFormError, setSettingsFormError] = React.useState<
- string | null
- >(null);
+ const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
+ !localStorage.getItem("analytics-consent"),
+ );
+
+ const config = useConfig();
+ const user = useGitHubUser();
+ const {
+ data: isAuthed,
+ isFetched,
+ isFetching: isFetchingAuth,
+ } = useIsAuthed();
+ const aiConfigOptions = useAIConfigOptions();
+
+ const gitHubAuthUrl = useGitHubAuthUrl({
+ gitHubToken,
+ appMode: config.data?.APP_MODE || null,
+ gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
+ });
React.useEffect(() => {
- if (user && !isGitHubErrorReponse(user)) {
- posthog.identify(user.login, {
- company: user.company,
- name: user.name,
- email: user.email,
- user: user.login,
- mode: window.__APP_MODE__ || "oss",
- });
- }
- }, [user]);
+ if (isFetched && !isAuthed) clearToken();
+ }, [isFetched, isAuthed]);
React.useEffect(() => {
- // We fetch this here instead of the data loader because the server seems to block
- // the retrieval when the session is closing -- preventing the screen from rendering until
- // the fetch is complete
- (async () => {
- try {
- const [models, agents, securityAnalyzers] = await Promise.all([
- OpenHands.getModels(),
- OpenHands.getAgents(),
- OpenHands.getSecurityAnalyzers(),
- ]);
- setSettingsFormData({ models, agents, securityAnalyzers });
- } catch (error) {
- setSettingsFormError("Failed to load settings, please reload the page");
- }
- })();
- }, []);
+ if (settings.LANGUAGE) {
+ i18n.changeLanguage(settings.LANGUAGE);
+ }
+ }, [settings.LANGUAGE]);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
- if (isGitHubErrorReponse(user)) {
+ if (user.isError) {
setAccountSettingsModalOpen(true);
}
- }, [user]);
-
- const handleUserLogout = () => {
- logoutFetcher.submit(
- {},
- {
- method: "POST",
- action: "/logout",
- },
- );
- };
+ }, [user.isError]);
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
- if (isGitHubErrorReponse(user)) handleUserLogout();
+ if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const handleEndSession = () => {
setStartNewProjectModalIsOpen(false);
dispatch(setCurrentAgentState(AgentState.LOADING));
- // call new session action and redirect to '/'
- endSessionFetcher.submit(new FormData(), {
- method: "POST",
- action: "/end-session",
- });
+ endSession();
};
return (
@@ -237,8 +135,8 @@ export default function MainApp() {
>