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). +

Star History Chart 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) { } />
- +
@@ -59,7 +60,6 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) { testId="connect-to-github" type="submit" text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)} - disabled={fetcher.state === "submitting"} className="bg-[#791B80] w-full" />
- +
); } 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 (
-
((_, ref) => { disabled={navigation.state === "submitting"} />
- + { 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 ( -
- {selectedPath} -
- ); - } + if (fileContent) { + if (isBase64Image(fileContent)) { + return ( +
+ {selectedPath} +
+ ); + } - if (isPDF(fileContent)) { - return ( -