Compare commits

..

47 Commits

Author SHA1 Message Date
Robert Brennan 50a0b1d91d fix llm err handling (#5958) 2025-01-01 17:00:18 -05:00
dependabot[bot] 3d4d66a8c2 chore(deps-dev): bump llama-index from 0.12.8 to 0.12.9 in the llama group (#5955)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 16:15:31 +01:00
Robert Brennan f3885cadc1 Fix CLI and headless after changes to eventstream (#5949)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-01-01 06:05:35 +01:00
Engel Nyst 2ec2f2538f Load the event stream fix after it's no longer a dataclass (#5948) 2024-12-31 22:03:57 +00:00
Engel Nyst 40d8245089 Fix history loading when state was corrupt/non-existent (#5946)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-31 21:46:35 +00:00
Robert Brennan d29cc61aa2 Remove while True in AgentController (#5868)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-12-31 16:10:36 -05:00
Engel Nyst a2e9e206e8 Reset a failed tool call (#5666)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-31 21:21:32 +01:00
Robert Brennan 7ae1f768fc remove logout on settings upgrade (#5945) 2024-12-31 18:36:59 +00:00
sp.wack e692e06acf fix(frontend): test act warnings (#5942) 2024-12-31 17:38:03 +00:00
dependabot[bot] 6f80dc5eee chore(deps-dev): bump chromadb from 0.5.23 to 0.6.0 in the chromadb group (#5934)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-31 18:28:04 +01:00
sp.wack aa6070624b chore(frontend): Replace deprecated nextui component prop onClick with onPress (#5851) 2024-12-31 21:13:53 +04:00
sp.wack b6c8aa27fa fix(frontend): a few issues with settings (#5940)
Co-authored-by: Robert Brennan <contact@rbren.io>
Co-authored-by: Robert Brennan <accounts@rbren.io>
2024-12-31 17:02:44 +00:00
Robert Brennan 3e9ba40a92 fix: remove logged VS Code token (#5939) 2024-12-31 15:51:02 +00:00
sp.wack ab0eabd88c fix(frontend): chat interface bg color (#5936) 2024-12-31 14:58:16 +00:00
sp.wack 4de0a27ed2 test(frontend): Settings logic and new API key UI (#5873) 2024-12-31 09:40:51 +00:00
tofarr c37e865c56 Add resizable and collapsible panel layout (#5926)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-30 22:40:07 +00:00
sp.wack 6523fcae6b feat(frontend): Multi-project support (#5376)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Robert Brennan <contact@rbren.io>
Co-authored-by: Robert Brennan <accounts@rbren.io>
2024-12-30 20:53:27 +00:00
tofarr d7a3ec69d9 Refactor to make FileConversationStore more extendable (#5922) 2024-12-30 11:51:56 -07:00
Robert Brennan bb578a2e9d Fix remote runtime (#5923) 2024-12-30 18:15:51 +00:00
sp.wack d8b33c4e78 Fix: Allow form submission with empty query if repo/files present (#5919)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-30 17:23:26 +00:00
dependabot[bot] 0e8e3c87f3 chore(deps): bump the version-all group with 4 updates (#5916)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 17:47:01 +01:00
diwu-sf 5e8c20f406 SocketIO init event ordering (#5714)
Co-authored-by: tofarr <tofarr@gmail.com>
2024-12-30 16:44:35 +00:00
tofarr 4dd40049ab Refactor ConversationStore to follow SettingsStore pattern (#5881)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-30 09:05:04 -07:00
Cheng Yang c7a8dcf079 chore(log): better json parse (#5581) 2024-12-31 00:04:21 +08:00
Vaishakh 2869d646b6 Use i18n Keys (#5286)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-12-30 15:57:18 +00:00
Robert Brennan 0e4e1b3316 Factor out ActionExecutionClient (#5796) 2024-12-30 15:32:13 +00:00
dependabot[bot] 37363a0a8d chore(deps): bump the version-all group across 1 directory with 5 updates (#5914)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-12-30 13:19:58 +00:00
OpenHands 14dc04474c Fix issue #5831: [Bug]: "Request failed with status code 409" on opening empty repo (#5833)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2024-12-30 12:12:31 +00:00
OpenHands 5502a9c448 Fix issue #5806: Tooltips for navbar (#5807)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-12-30 20:40:06 +09:00
Engel Nyst e718089802 Fix #5637: Check for errors in IPython content instead of message (#5895)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-30 12:45:36 +09:00
Boxuan Li 95f7a6a4dc fn_call_converter: Fix newline in in-context learning prompt (#5903) 2024-12-29 13:20:05 -08:00
Boxuan Li 7318c22928 web read function call: Fix typo in prompt (#5902) 2024-12-29 12:14:19 -08:00
mamoodi 78aef2b150 Update headless with no auto continue (#5901) 2024-12-29 20:10:10 +01:00
Ketan Ramaneti 194181a420 check node.js req for 20.x or later (#5877) 2024-12-29 11:55:24 +01:00
Rohit Malhotra 2933f07440 [Fix]: Prevent back tick escape (#5897) 2024-12-29 01:02:14 -05:00
OpenHands 037457dec9 Fix issue #5890: Add an automatic check of version consistency in documentation (#5891)
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
2024-12-29 04:28:47 +00:00
Graham Neubig 7f665c2fb6 Improve test coverage of codeact_agent folder (#5757)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
2024-12-28 20:12:34 -05:00
Boxuan Li ebb2d86ce3 Headless or endless? Rewrite auto continue response in headless mode (#5879) 2024-12-28 10:25:50 -08:00
Boxuan Li 6a4442e590 [Evaluation] Add summarise_results script for TheAgentCompany benchmark (#5811) 2024-12-27 20:33:41 -08:00
mamoodi 157ff4a4b9 Fix: Prevent submission of empty prompts with spaces (#5874)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:31:28 -05:00
mamoodi cc928e6d3f Fix: Add vertical scrolling to file content viewer (#5872)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:03:15 -05:00
Robert Brennan 6a75800e1b fix github auth for settings (#5871) 2024-12-27 14:15:55 -05:00
tofarr c9cecbc461 Responsive splash screen (#5864) 2024-12-27 11:12:48 -07:00
Robert Brennan 97b1867ea1 Fix for settings update (#5858) 2024-12-27 16:28:11 +00:00
dependabot[bot] 9bdc1df2df chore(deps): bump boto3 from 1.35.87 to 1.35.88 in the version-all group (#5861)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 17:15:29 +01:00
sp.wack 9d984aaa30 chore(frontend): Upgrade to React 19 (#5835) 2024-12-27 19:10:41 +04:00
Boxuan Li 5ed80b5c32 [doc] Fix link in TheAgentCompany benchmark's README.md (#5848) 2024-12-27 22:21:02 +08:00
188 changed files with 4974 additions and 1896 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import os
import re
import sys
from typing import Set, Tuple
def find_version_references(directory: str) -> Tuple[Set[str], Set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory
if '.git' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
openhands_versions, runtime_versions = find_version_references(repo_root)
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()
+13
View File
@@ -53,3 +53,16 @@ jobs:
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks
run: pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py
+9 -4
View File
@@ -185,12 +185,17 @@ jobs:
- name: Install OpenHands
uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ github.event.comment.body || '' }}
REVIEW_BODY: ${{ github.event.review.body || '' }}
LABEL_NAME: ${{ github.event.label.name || '' }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const commentBody = `${{ github.event.comment.body || '' }}`.trim();
const reviewBody = `${{ github.event.review.body || '' }}`.trim();
const labelName = `${{ github.event.label.name || '' }}`.trim();
const eventName = `${{ github.event_name }}`.trim();
const commentBody = process.env.COMMENT_BODY.trim();
const reviewBody = process.env.REVIEW_BODY.trim();
const labelName = process.env.LABEL_NAME.trim();
const eventName = process.env.EVENT_NAME.trim();
// Check conditions
const isExperimentalLabel = labelName === "fix-me-experimental";
+1 -1
View File
@@ -8,7 +8,7 @@ Otherwise, you can clone the OpenHands project directly.
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu <= 22.04]
* [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
* [Python](https://www.python.org/downloads/) = 3.12
* [NodeJS](https://nodejs.org/en/download/package-manager) >= 18.17.1
* [NodeJS](https://nodejs.org/en/download/package-manager) >= 20.x
* [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
* OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential`
+2 -2
View File
@@ -81,10 +81,10 @@ check-nodejs:
@if command -v node > /dev/null; then \
NODE_VERSION=$(shell node --version | sed -E 's/v//g'); \
IFS='.' read -r -a NODE_VERSION_ARRAY <<< "$$NODE_VERSION"; \
if [ "$${NODE_VERSION_ARRAY[0]}" -gt 18 ] || ([ "$${NODE_VERSION_ARRAY[0]}" -eq 18 ] && [ "$${NODE_VERSION_ARRAY[1]}" -gt 17 ]) || ([ "$${NODE_VERSION_ARRAY[0]}" -eq 18 ] && [ "$${NODE_VERSION_ARRAY[1]}" -eq 17 ] && [ "$${NODE_VERSION_ARRAY[2]}" -ge 1 ]); then \
if [ "$${NODE_VERSION_ARRAY[0]}" -ge 20 ]; then \
echo "$(BLUE)Node.js $$NODE_VERSION is already installed.$(RESET)"; \
else \
echo "$(RED)Node.js 18.17.1 or later is required. Please install Node.js 18.17.1 or later to continue.$(RESET)"; \
echo "$(RED)Node.js 20.x or later is required. Please install Node.js 20.x or later to continue.$(RESET)"; \
exit 1; \
fi; \
else \
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.16
docker.all-hands.dev/all-hands-ai/openhands:0.17
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.16
docker.all-hands.dev/all-hands-ai/openhands:0.17
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+1 -1
View File
@@ -12,7 +12,7 @@ To run OpenHands in headless mode with Python,
and then run:
```bash
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
poetry run python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
You'll need to be sure to set your model, API key, and other settings via environment variables
+5 -6
View File
@@ -1,14 +1,13 @@
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { HomepageHeader } from "../components/HomepageHeader/HomepageHeader";
import { Welcome } from "../components/Welcome/Welcome";
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
import { translate } from '@docusaurus/Translate';
export function Header({ title, summary }): JSX.Element {
return (
<div>
<h1>{title}</h1>
<h2 style={{ fontSize: "3rem" }}>{summary}</h2>
<h2 style={{ fontSize: '3rem' }}>{summary}</h2>
</div>
);
}
@@ -23,7 +22,7 @@ export default function Home(): JSX.Element {
message: 'Code Less, Make More',
})}
>
<HomepageHeader />
<HomepageHeader />
</Layout>
);
}
+1 -1
View File
@@ -63,7 +63,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
@@ -43,7 +43,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-slim',
@@ -50,7 +50,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.11-bookworm',
+1 -1
View File
@@ -61,7 +61,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image=BIOCODER_BENCH_CONTAINER_IMAGE,
+1 -1
View File
@@ -74,7 +74,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
@@ -39,7 +39,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
@@ -124,7 +124,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
@@ -65,7 +65,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
+1 -1
View File
@@ -50,7 +50,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
+1 -1
View File
@@ -43,7 +43,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
+1 -1
View File
@@ -64,7 +64,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
@@ -85,7 +85,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
@@ -48,7 +48,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-logic-reasoning:v1.0',
+1 -1
View File
@@ -58,7 +58,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
+1 -1
View File
@@ -106,7 +106,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-mint:v1.0',
+1 -1
View File
@@ -80,7 +80,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='public.ecr.aws/i5g0m1f6/ml-bench',
@@ -62,7 +62,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
max_budget_per_task=4,
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
@@ -76,7 +76,7 @@ def get_config(instance: pd.Series) -> AppConfig:
)
config = AppConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
+1 -1
View File
@@ -121,7 +121,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
@@ -5,7 +5,7 @@ This folder contains the evaluation harness that we built on top of the original
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), [launch services](https://github.com/TheAgentCompany/TheAgentCompany/blob/main/docs/SETUP.md).
2. [Run Evaluation](#run-inference-on-the-agent-company-instances): Run all tasks and get the evaluation results.
2. [Run Evaluation](#run-inference-on-the-agent-company-tasks): Run all tasks and get the evaluation results.
## Setup Environment and LLM Configuration
@@ -0,0 +1,316 @@
###########################################################################################################
# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/summarise_results.py
###########################################################################################################
import glob
import json
import os
import re
import sys
from typing import Dict, Tuple
def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""
Calculate the cost of the model call.
"""
if 'claude-3-5-sonnet' in model.lower():
# https://www.anthropic.com/pricing#anthropic-api, accessed 12/11/2024
return 0.000003 * prompt_tokens + 0.000015 * completion_tokens
elif 'gpt-4o' in model.lower():
# https://openai.com/api/pricing/, accessed 12/11/2024
return 0.0000025 * prompt_tokens + 0.00001 * completion_tokens
elif 'gemini-1.5-pro' in model.lower():
# https://ai.google.dev/pricing#1_5pro, accessed 12/11/2024
# assuming prompts up to 128k tokens
cost = 0.00000125 * prompt_tokens + 0.000005 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'gemini-2.0-flash-exp' in model.lower():
# price unknown for gemini-2.0-flash-exp, assuming same price as gemini-1.5-flash
cost = 0.000000075 * prompt_tokens + 0.0000003 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'qwen2-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/11/2024
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'qwen2p5-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/14/2024
return 0.0000012 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-405b-instruct' in model.lower():
# assuming hosted on Fireworks AI
# https://fireworks.ai/pricing, accessed 12/11/2024
return 0.000003 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'llama-v3p3-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'amazon.nova-pro-v1:0' in model.lower():
# assuming hosted on Amazon Bedrock
# https://aws.amazon.com/bedrock/pricing/, accessed 12/11/2024
return 0.0000008 * prompt_tokens + 0.0000032 * completion_tokens
else:
raise ValueError(f'Unknown model: {model}')
def analyze_eval_json_file(filepath: str) -> Tuple[int, int]:
"""
Analyze a single eval JSON file and extract the total and result from final_score.
Args:
filepath: Path to the JSON file
Returns:
Tuple containing (total, result) from final_score
"""
try:
with open(filepath, 'r') as f:
data = json.load(f)
final_score = data.get('final_score', {})
return (final_score.get('total', 0), final_score.get('result', 0))
except json.JSONDecodeError as e:
print(f'Error decoding JSON in {filepath}: {e}')
return (0, 0)
except Exception as e:
print(f'Error processing {filepath}: {e}')
return (0, 0)
def analyze_traj_json_file(filepath: str) -> Tuple[int, float]:
"""
Analyze a single trajectory JSON file and extract the steps and tokens
for each step. Then estimate the cost based on the tokens and the model type.
Note: this is assuming there's no prompt caching at all.
"""
steps: int = 0
cost: float = 0.0
with open(filepath, 'r') as f:
data = json.load(f)
response_id = None
for action in data:
if 'tool_call_metadata' in action:
if action['tool_call_metadata']['model_response']['id'] != response_id:
response_id = action['tool_call_metadata']['model_response']['id']
else:
# openhands displays the same model response meta data multiple times, when
# a single LLM call leads to multiple actions and observations.
continue
steps += 1
usage = action['tool_call_metadata']['model_response']['usage']
model: str = action['tool_call_metadata']['model_response']['model']
prompt_tokens = usage['prompt_tokens']
completion_tokens = usage['completion_tokens']
cost += calculate_cost(model, prompt_tokens, completion_tokens)
return (steps, cost)
def analyze_folder(
folder_path: str,
) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, Tuple[int, float]]]:
"""
Analyze all eval_*.json & traj_*.json files in the specified folder.
Args:
folder_path: Path to the folder containing JSON files
Returns:
dictionaries:
- eval_results: Dictionary with filename as key and (total, result) tuple as value
- traj_results: Dictionary with filename as key and (steps, cost) tuple as value
"""
eval_results = {}
traj_results = {}
eval_pattern = os.path.join(folder_path, 'eval_*.json')
traj_pattern = os.path.join(folder_path, 'traj_*.json')
for filepath in glob.glob(eval_pattern):
filename = os.path.basename(filepath)
total, result = analyze_eval_json_file(filepath)
key = re.search(r'eval_(.+)\.json', filename).group(1)
eval_results[key] = (total, result)
for filepath in glob.glob(traj_pattern):
filename = os.path.basename(filepath)
steps, cost = analyze_traj_json_file(filepath)
key = re.search(r'traj_(.+)\.json', filename).group(1)
traj_results[key] = (steps, cost)
return eval_results, traj_results
def get_task_nature_category(task_name: str) -> str:
"""
Get the nature category of the task.
"""
task_nature = task_name.split('-')[0]
if task_nature.lower() in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance']:
return task_nature
else:
return 'other'
def calculate_score(total: int, result: int) -> float:
"""
Calculate the score as a number between 0 and 1.
Formula: score = (result / total) * 0.5 + (result // total) * 0.5
Explanation:
- (result / total) * 0.5: This is the completion ratio, scaled down to a 0-0.5 range.
- (result // total) * 0.5: This is a binary score indicating whether the task was completed or not.
Args:
total: Total possible points
result: Actual points achieved
Returns:
Score as a number between 0 and 1
"""
return (result / total * 0.5) + (result // total * 0.5)
def is_perfect_completion(total: int, result: int) -> bool:
"""
Check if the task achieved perfect completion.
Args:
total: Total possible points
result: Actual points achieved
Returns:
True if result equals total, False otherwise
"""
return total > 0 and total == result
def main():
if len(sys.argv) != 2:
print('Usage: poetry run python summarise_results.py <folder_path>')
sys.exit(1)
folder_path = sys.argv[1]
if not os.path.isdir(folder_path):
print(f"Error: '{folder_path}' is not a valid directory")
sys.exit(1)
eval_results, traj_results = analyze_folder(folder_path)
if not eval_results:
print(f'No eval_*.json files found in {folder_path}')
return
# Create list of results with completion ratios for sorting
detailed_results = [
(
task_name,
total,
result,
calculate_score(total, result),
is_perfect_completion(total, result),
get_task_nature_category(task_name),
)
for task_name, (total, result) in eval_results.items()
]
# Sort by score in descending order
detailed_results.sort(key=lambda x: (-x[3], x[0]))
# Calculate perfect completion stats
perfect_completions = sum(
1 for _, _, _, _, is_perfect, _ in detailed_results if is_perfect
)
# Print header
print('\n# Evaluation Results Report')
print('\n## Results per File')
print('\n*Sorted by score (⭐ indicates perfect completion)*\n')
# Print table header
print(
'| Filename | Total | Result | Score | Steps | Cost (assuming no prompt caching)|'
)
print('|----------|--------|---------|-------|-------|------|')
# Print individual file results
for task_name, total, result, score, is_perfect, task_nature in detailed_results:
perfect_marker = '' if is_perfect else ''
print(
f'| {task_name} | {total:,} | {result:,} | {score:.2f}{perfect_marker} | {traj_results[task_name][0]} | {traj_results[task_name][1]:.2f} |'
)
# Print summary section
print('\n## Summary\n')
print(f'**Tasks Evaluated:** {len(eval_results)}\n')
print(
f'**Perfect Completions:** {perfect_completions}/{len(eval_results)} ({(perfect_completions/len(eval_results)*100):.2f}%)\n'
)
overall_score = (
sum(score for _, _, _, score, _, _ in detailed_results)
/ len(detailed_results)
* 100
)
avg_steps = sum(steps for steps, _ in traj_results.values()) / len(traj_results)
avg_cost = sum(cost for _, cost in traj_results.values()) / len(traj_results)
print(f'**Overall Score:** {overall_score:.2f}%\n')
print(f'**Average Steps:** {avg_steps:.2f}\n')
print(f'**Average Cost (USD):** {avg_cost:.2f}\n')
# Additional statistics
if detailed_results:
highest_score = max(score for _, _, _, score, _, _ in detailed_results)
lowest_score = min(score for _, _, _, score, _, _ in detailed_results)
median_score = detailed_results[len(detailed_results) // 2][3]
avg_score = sum(score for _, _, _, score, _, _ in detailed_results) / len(
detailed_results
)
print('\n## Statistics\n')
print('| Metric | Value |')
print('|---------|--------|')
print(f'| Highest Task Score | {highest_score*100:.2f}% |')
print(f'| Lowest Task Score | {lowest_score*100:.2f}% |')
print(f'| Median Task Score | {median_score*100:.2f}% |')
print(f'| Average Task Score | {avg_score*100:.2f}% |')
# compute avg score per nature category
print('\n## Statistics per Nature Category\n')
print('| Metric | Value |')
print('|---------|--------|')
for task_nature in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance', 'other']:
num_of_tasks = sum(
1
for _, _, _, _, _, nature_category in detailed_results
if nature_category == task_nature
)
task_nature_score = (
sum(
score
for _, _, _, score, _, nature_category in detailed_results
if nature_category == task_nature
)
/ num_of_tasks
)
perfect_completions = sum(
1
for _, _, _, _, is_perfect, nature_category in detailed_results
if nature_category == task_nature and is_perfect
)
print(
f'| Perfect Completions for {task_nature} | {perfect_completions}/{num_of_tasks} ({perfect_completions/num_of_tasks*100:.2f}%) |'
)
print(f'| Average Score for {task_nature} | {task_nature_score*100:.2f}% |')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -44,7 +44,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
+1 -1
View File
@@ -53,7 +53,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
+1 -1
View File
@@ -42,7 +42,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
# use default base_container_image
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { retrieveLatestGitHubCommit } from "../../src/api/github";
describe("retrieveLatestGitHubCommit", () => {
const { githubGetMock } = vi.hoisted(() => ({
githubGetMock: vi.fn(),
}));
vi.mock("../../src/api/github-axios-instance", () => ({
github: {
get: githubGetMock,
},
}));
it("should return the latest commit when repository has commits", async () => {
const mockCommit = {
sha: "123abc",
commit: {
message: "Initial commit",
},
};
githubGetMock.mockResolvedValueOnce({
data: [mockCommit],
});
const result = await retrieveLatestGitHubCommit("user/repo");
expect(result).toEqual(mockCommit);
});
it("should return null when repository is empty", async () => {
const error = new Error("Repository is empty");
(error as any).response = { status: 409 };
githubGetMock.mockRejectedValueOnce(error);
const result = await retrieveLatestGitHubCommit("user/empty-repo");
expect(result).toBeNull();
});
it("should throw error for other error cases", async () => {
const error = new Error("Network error");
(error as any).response = { status: 500 };
githubGetMock.mockRejectedValueOnce(error);
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
});
});
@@ -28,8 +28,8 @@ describe("AccountSettingsContextMenu", () => {
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(screen.getByText("Account Settings")).toBeInTheDocument();
expect(screen.getByText("Logout")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument();
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
@@ -42,7 +42,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
const accountSettingsOption = screen.getByText("Account Settings");
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
@@ -58,7 +58,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
const logoutOption = screen.getByText("Logout");
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
@@ -74,7 +74,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
const logoutOption = screen.getByText("Logout");
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
@@ -90,7 +90,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
const accountSettingsButton = screen.getByText("Account Settings");
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsButton);
await user.click(document.body);
@@ -0,0 +1,274 @@
import { render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
const onChangeTitle = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render the conversation card", () => {
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
const card = screen.getByTestId("conversation-card");
const title = within(card).getByTestId("conversation-card-title");
expect(title).toHaveValue("Conversation 1");
within(card).getByText(expectedDate);
});
it("should render the repo if available", () => {
const { rerender } = render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
expect(
screen.queryByTestId("conversation-card-repo"),
).not.toBeInTheDocument();
rerender(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo="org/repo"
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("conversation-card-repo");
});
it("should call onClick when the card is clicked", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const card = screen.getByTestId("conversation-card");
await user.click(card);
expect(onClick).toHaveBeenCalled();
});
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
});
test("clicking the repo should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo="org/repo"
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const repo = screen.getByTestId("conversation-card-repo");
await user.click(repo);
expect(onClick).not.toHaveBeenCalled();
});
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
await user.clear(title);
await user.type(title, "New Conversation Name ");
await user.tab();
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
expect(title).toHaveValue("New Conversation Name");
});
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const title = screen.getByTestId("conversation-card-title");
await user.clear(title);
await user.tab();
expect(onChangeTitle).not.toHaveBeenCalled();
expect(title).toHaveValue("Conversation 1");
});
test("clicking the title should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const title = screen.getByTestId("conversation-card-title");
await user.click(title);
expect(onClick).not.toHaveBeenCalled();
});
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onClick).not.toHaveBeenCalled();
});
describe("state indicator", () => {
it("should render the 'cold' indicator by default", () => {
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("cold-indicator");
});
it("should render the other indicators when provided", () => {
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
name="Conversation 1"
repo={null}
lastUpdated="2021-10-01T12:00:00Z"
state="warm"
/>,
);
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
screen.getByTestId("warm-indicator");
});
});
});
@@ -0,0 +1,267 @@
import { render, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
QueryClient,
QueryClientConfig,
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
const renderConversationPanel = (config?: QueryClientConfig) =>
render(<ConversationPanel onClose={onCloseMock} />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(config)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
const { endSessionMock } = vi.hoisted(() => ({
endSessionMock: vi.fn(),
}));
beforeAll(() => {
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
Link: ({ children }: React.PropsWithChildren) => children,
useNavigate: vi.fn(() => vi.fn()),
useLocation: vi.fn(() => ({ pathname: "/conversation" })),
useParams: vi.fn(() => ({ conversationId: "2" })),
}));
vi.mock("#/hooks/use-end-session", async (importOriginal) => ({
...(await importOriginal<typeof import("#/hooks/use-end-session")>()),
useEndSession: vi.fn(() => endSessionMock),
}));
});
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("should render the conversations", async () => {
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
});
it("should display an empty state when there are no conversations", async () => {
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue([]);
renderConversationPanel();
const emptyState = await screen.findByText("No conversations found");
expect(emptyState).toBeInTheDocument();
});
it("should handle an error when fetching conversations", async () => {
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
renderConversationPanel({
defaultOptions: {
queries: {
retry: false,
},
},
});
const error = await screen.findByText("Failed to fetch conversations");
expect(error).toBeInTheDocument();
});
it("should cancel deleting a conversation", async () => {
const user = userEvent.setup();
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
expect(
within(cards[0]).queryByTestId("delete-button"),
).not.toBeInTheDocument();
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
// Click the first delete button
await user.click(deleteButton);
// Cancel the deletion
const cancelButton = screen.getByText("Cancel");
await user.click(cancelButton);
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
// Ensure the conversation is not deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
});
it("should call endSession after deleting a conversation that is the current session", async () => {
const user = userEvent.setup();
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
const ellipsisButton = within(cards[1]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
// Click the second delete button
await user.click(deleteButton);
// Confirm the deletion
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
expect(endSessionMock).toHaveBeenCalledOnce();
});
it("should delete a conversation", async () => {
const user = userEvent.setup();
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
// Click the first delete button
await user.click(deleteButton);
// Confirm the deletion
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(1);
});
it("should rename a conversation", async () => {
const updateUserConversationSpy = vi.spyOn(
OpenHands,
"updateUserConversation",
);
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
const title = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(title);
await user.type(title, "Conversation 1 Renamed");
await user.tab();
// Ensure the conversation is renamed
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
name: "Conversation 1 Renamed",
});
});
it("should not rename a conversation when the name is unchanged", async () => {
const updateUserConversationSpy = vi.spyOn(
OpenHands,
"updateUserConversation",
);
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
const title = within(cards[0]).getByTestId("conversation-card-title");
await user.click(title);
await user.tab();
// Ensure the conversation is not renamed
expect(updateUserConversationSpy).not.toHaveBeenCalled();
await user.type(title, "Conversation 1");
await user.click(title);
await user.tab();
expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
await user.click(title);
await user.tab();
expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
});
it("should call onClose after clicking a card", async () => {
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
const firstCard = cards[0];
await userEvent.click(firstCard);
expect(onCloseMock).toHaveBeenCalledOnce();
});
describe("New Conversation Button", () => {
it("should display a confirmation modal when clicking", async () => {
const user = userEvent.setup();
renderConversationPanel();
expect(
screen.queryByTestId("confirm-new-conversation-modal"),
).not.toBeInTheDocument();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const modal = screen.getByTestId("confirm-new-conversation-modal");
expect(modal).toBeInTheDocument();
});
it("should call endSession and close panel after confirming", async () => {
const user = userEvent.setup();
renderConversationPanel();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
expect(endSessionMock).toHaveBeenCalledOnce();
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should close the modal when cancelling", async () => {
const user = userEvent.setup();
renderConversationPanel();
const newProjectButton = screen.getByTestId("new-conversation-button");
await user.click(newProjectButton);
const cancelButton = screen.getByText("Cancel");
await user.click(cancelButton);
expect(endSessionMock).not.toHaveBeenCalled();
expect(
screen.queryByTestId("confirm-new-conversation-modal"),
).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,46 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
const renderSidebar = () => {
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: Sidebar,
},
]);
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
};
describe("Sidebar", () => {
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should have the conversation panel open by default",
() => {
renderSidebar();
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
},
);
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should toggle the conversation panel",
async () => {
const user = userEvent.setup();
renderSidebar();
const projectPanelButton = screen.getByTestId(
"toggle-conversation-panel",
);
await user.click(projectPanelButton);
expect(
screen.queryByTestId("conversation-panel"),
).not.toBeInTheDocument();
},
);
});
@@ -52,14 +52,10 @@ describe("BaseModal", () => {
expect(screen.getByText("Save")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByText("Save"));
});
await userEvent.click(screen.getByText("Save"));
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
await act(async () => {
await userEvent.click(screen.getByText("Cancel"));
});
await userEvent.click(screen.getByText("Cancel"));
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
});
@@ -80,9 +76,7 @@ describe("BaseModal", () => {
/>,
);
await act(async () => {
await userEvent.click(screen.getByText("Save"));
});
await userEvent.click(screen.getByText("Save"));
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
});
@@ -58,7 +58,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const accountSettingsOption = screen.getByText("Account Settings");
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
@@ -79,7 +79,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const logoutOption = screen.getByText("Logout");
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
@@ -99,7 +99,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const logoutOption = screen.getByText("Logout");
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
@@ -0,0 +1,83 @@
import { createRoutesStub } from "react-router";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import App from "#/routes/_oh.app/route";
import OpenHands from "#/api/open-hands";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
describe("App", () => {
const RouteStub = createRoutesStub([
{ Component: App, path: "/conversation/:conversationId" },
]);
const { endSessionMock } = vi.hoisted(() => ({
endSessionMock: vi.fn(),
}));
beforeAll(() => {
vi.mock("#/hooks/use-end-session", () => ({
useEndSession: vi.fn(() => endSessionMock),
}));
vi.mock("#/hooks/use-terminal", () => ({
useTerminal: vi.fn(),
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render", async () => {
renderWithProviders(<RouteStub initialEntries={["/conversation/123"]} />);
await screen.findByTestId("app-route");
});
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
"should call endSession if the user does not have permission to view conversation",
async () => {
const errorToastSpy = vi.spyOn(toast, "error");
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue(null);
renderWithProviders(
<RouteStub initialEntries={["/conversation/9999"]} />,
);
await waitFor(() => {
expect(endSessionMock).toHaveBeenCalledOnce();
expect(errorToastSpy).toHaveBeenCalledOnce();
});
},
);
it("should not call endSession if the user has permission", async () => {
const errorToastSpy = vi.spyOn(toast, "error");
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "9999",
lastUpdated: "",
name: "",
repo: "",
state: "cold",
});
const { rerender } = renderWithProviders(
<RouteStub initialEntries={["/conversation/9999"]} />,
);
await waitFor(() => {
expect(endSessionMock).not.toHaveBeenCalled();
expect(errorToastSpy).not.toHaveBeenCalled();
});
rerender(<RouteStub initialEntries={["/conversation"]} />);
await waitFor(() => {
expect(endSessionMock).not.toHaveBeenCalled();
expect(errorToastSpy).not.toHaveBeenCalled();
});
});
});
+155 -91
View File
@@ -8,13 +8,13 @@
"name": "openhands-frontend",
"version": "0.17.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@tanstack/react-query": "^5.62.11",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -27,12 +27,12 @@
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"posthog-js": "^1.203.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.2.0",
"react-i18next": "^15.4.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
@@ -43,11 +43,12 @@
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"vite": "^5.4.11",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
@@ -56,8 +57,8 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -77,7 +78,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.11",
"lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
@@ -1595,17 +1596,17 @@
}
},
"node_modules/@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"version": "4.7.0-rc.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0-rc.0.tgz",
"integrity": "sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.4.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mswjs/interceptors": {
@@ -1626,6 +1627,21 @@
"node": ">=18"
}
},
"node_modules/@mswjs/socket.io-binding": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@mswjs/socket.io-binding/-/socket.io-binding-0.1.1.tgz",
"integrity": "sha512-mtFDHC5XMeti43toe3HBynD4uBxvUA2GfJVC6TDfhOQlH+G2hf5znNTSa75A30XdWL0P6aNqUKpcNo6L0Wop+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mswjs/interceptors": "^0.37.1",
"engine.io-parser": "^5.2.3",
"socket.io-parser": "^4.2.4"
},
"peerDependencies": {
"@mswjs/interceptors": "*"
}
},
"node_modules/@nextui-org/accordion": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@nextui-org/accordion/-/accordion-2.2.6.tgz",
@@ -2241,6 +2257,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/listbox/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/menu": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@nextui-org/menu/-/menu-2.2.8.tgz",
@@ -2579,6 +2612,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/select/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/shared-icons": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz",
@@ -5321,9 +5371,9 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.62.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.10.tgz",
"integrity": "sha512-1e1WpHM5oGf27nWM/NWLY62/X9pbMBWa6ErWYmeuK0OqB9/g9UzA59ogiWbxCmS2wtAFQRhOdHhfSofrkhPl2g==",
"version": "5.62.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz",
"integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.9"
@@ -5336,23 +5386,6 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz",
@@ -5603,30 +5636,23 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz",
"integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
"integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-highlight": {
@@ -8124,9 +8150,9 @@
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.23.7",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz",
"integrity": "sha512-OygGC8kIcDhXX+6yAZRGLqwi2CmEXCbLQixeGUgYeR+Qwlppqmo7DIDr8XibtEBZp+fJcoYpoatp5qwLMEdcqQ==",
"version": "1.23.8",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.8.tgz",
"integrity": "sha512-lfab8IzDn6EpI1ibZakcgS6WsfEBiB+43cuJo+wgylx1xKXf+Sp+YR3vFuQwC/u3sxYwV8Cxe3B0DpVUu/WiJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8165,8 +8191,10 @@
"object-inspect": "^1.13.3",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
"own-keys": "^1.0.0",
"regexp.prototype.flags": "^1.5.3",
"safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
@@ -11179,13 +11207,13 @@
"license": "MIT"
},
"node_modules/lint-staged": {
"version": "15.2.11",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.11.tgz",
"integrity": "sha512-Ev6ivCTYRTGs9ychvpVw35m/bcNDuBN+mnTeObCL5h+boS5WzBEC6LHI4I9F/++sZm1m+J2LEiy0gxL/R9TBqQ==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
"integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "~5.3.0",
"chalk": "~5.4.1",
"commander": "~12.1.0",
"debug": "~4.4.0",
"execa": "~8.0.1",
@@ -11207,9 +11235,9 @@
}
},
"node_modules/lint-staged/node_modules/chalk": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11544,6 +11572,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -13269,6 +13298,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
"integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.2.6",
"object-keys": "^1.1.1",
"safe-push-apply": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13552,14 +13599,14 @@
}
},
"node_modules/pkg-types": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz",
"integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
"integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.2",
"mlly": "^1.7.3",
"pathe": "^1.1.2"
}
},
@@ -13763,9 +13810,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.203.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.1.tgz",
"integrity": "sha512-r/WiSyz6VNbIKEV/30+aD5gdrYkFtmZwvqNa6h9frl8hG638v098FrXaq3EYzMcCdkQf3phaZTDIAFKegpiTjw==",
"version": "1.203.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.2.tgz",
"integrity": "sha512-3aLpEhM4i9sQQtobRmDttJ3rTW1+gwQ9HL7QiOeDueE2T7CguYibYS7weY1UhXMerx5lh1A7+szlOJTTibifLQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -13781,9 +13828,9 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.25.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.25.3.tgz",
"integrity": "sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==",
"version": "10.25.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz",
"integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -14067,28 +14114,24 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.0.0"
}
},
"node_modules/react-highlight": {
@@ -14117,9 +14160,9 @@
}
},
"node_modules/react-i18next": {
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.2.0.tgz",
"integrity": "sha512-iJNc8111EaDtVTVMKigvBtPHyrJV+KblWG73cUxqp+WmJCcwkzhWNFXmkAD5pwP2Z4woeDj/oXDdbjDsb3Gutg==",
"version": "15.4.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz",
"integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
@@ -14888,6 +14931,30 @@
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
"integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"isarray": "^2.0.5"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-push-apply/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
@@ -14926,13 +14993,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.0.10",
+11 -10
View File
@@ -7,13 +7,13 @@
"node": ">=20.0.0"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@tanstack/react-query": "^5.62.11",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -26,12 +26,12 @@
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"posthog-js": "^1.203.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.2.0",
"react-i18next": "^15.4.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
@@ -42,7 +42,7 @@
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"vite": "^5.4.11",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
@@ -75,6 +75,7 @@
]
},
"devDependencies": {
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
@@ -83,8 +84,8 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -104,7 +105,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.11",
"lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
+15 -3
View File
@@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.7.0'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -199,7 +199,19 @@ async function getResponse(event, client, requestId) {
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
+23 -10
View File
@@ -106,15 +106,28 @@ export const retrieveGitHubUser = async () => {
export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit> => {
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
{
params: {
per_page: 1,
): Promise<GitHubCommit | null> => {
try {
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
{
params: {
per_page: 1,
},
},
},
);
return response.data[0];
);
return response.data[0] || null;
} catch (error) {
if (!error || typeof error !== "object") {
throw new Error("Unknown error occurred");
}
const axiosError = error as { response?: { status: number } };
if (axiosError.response?.status === 409) {
// Repository is empty, no commits yet
return null;
}
throw new Error(
error instanceof Error ? error.message : "Unknown error occurred",
);
}
};
+62 -14
View File
@@ -8,8 +8,10 @@ import {
GetConfigResponse,
GetVSCodeUrlResponse,
AuthenticateResponse,
Conversation,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings } from "#/services/settings";
class OpenHands {
/**
@@ -219,6 +221,52 @@ class OpenHands {
return data;
}
static async getUserConversations(): Promise<Conversation[]> {
const { data } = await openHands.get<Conversation[]>("/api/conversations");
return data;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
static async updateUserConversation(
conversationId: string,
conversation: Partial<Omit<Conversation, "id">>,
): Promise<void> {
await openHands.put(`/api/conversations/${conversationId}`, conversation);
}
static async createConversation(
githubToken?: string,
selectedRepository?: string,
): Promise<Conversation> {
const body = {
github_token: githubToken,
selected_repository: selectedRepository,
};
const { data } = await openHands.post<Conversation>(
"/api/conversations",
body,
);
// TODO: remove this once we have a multi-conversation UI
localStorage.setItem("latest_conversation_id", data.conversation_id);
return data;
}
static async getConversation(
conversationId: string,
): Promise<Conversation | null> {
const { data } = await openHands.get<Conversation | null>(
`/api/conversations/${conversationId}`,
);
return data;
}
static async searchEvents(
conversationId: string,
params: {
@@ -248,22 +296,22 @@ class OpenHands {
return data;
}
static async newConversation(params: {
githubToken?: string;
args?: Record<string, unknown>;
selectedRepository?: string;
}): Promise<{ conversation_id: string }> {
const { data } = await openHands.post<{
conversation_id: string;
}>("/api/conversations", {
github_token: params.githubToken,
args: params.args,
selected_repository: params.selectedRepository,
});
// TODO: remove this once we have a multi-conversation UI
localStorage.setItem("latest_conversation_id", data.conversation_id);
/**
* Get the settings from the server or use the default settings if not found
*/
static async getSettings(): Promise<ApiSettings> {
const { data } = await openHands.get<ApiSettings>("/api/settings");
return data;
}
/**
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}
}
export default OpenHands;
+10
View File
@@ -1,3 +1,5 @@
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
export interface ErrorResponse {
error: string;
}
@@ -57,3 +59,11 @@ export interface AuthenticateResponse {
message?: string;
error?: string;
}
export interface Conversation {
conversation_id: string;
name: string;
repo: string | null;
lastUpdated: string;
state: ProjectState;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function ArrowIcon(): JSX.Element {
function ArrowIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function CogTooth(): JSX.Element {
function CogTooth() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function ConfirmIcon(): JSX.Element {
function ConfirmIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function PauseIcon(): JSX.Element {
function PauseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function PlayIcon(): JSX.Element {
function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function RejectIcon(): JSX.Element {
function RejectIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
function StopIcon(): JSX.Element {
function StopIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -8,7 +8,7 @@ import {
FaPython,
} from "react-icons/fa";
export const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
export const EXTENSION_ICON_MAP: Record<string, React.ReactNode> = {
js: <DiJavascript />,
ts: <DiJavascript />,
py: <FaPython />,
@@ -1,7 +1,9 @@
import { useTranslation } from "react-i18next";
import { ContextMenu } from "./context-menu";
import { ContextMenuListItem } from "./context-menu-list-item";
import { ContextMenuSeparator } from "./context-menu-separator";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { I18nKey } from "#/i18n/declaration";
interface AccountSettingsContextMenuProps {
onClickAccountSettings: () => void;
@@ -17,6 +19,7 @@ export function AccountSettingsContextMenu({
isLoggedIn,
}: AccountSettingsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return (
<ContextMenu
@@ -25,11 +28,11 @@ export function AccountSettingsContextMenu({
className="absolute left-full -top-1 z-10"
>
<ContextMenuListItem onClick={onClickAccountSettings}>
Account Settings
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
</ContextMenuListItem>
<ContextMenuSeparator />
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
Logout
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</ContextMenu>
);
@@ -1,18 +1,20 @@
import { cn } from "#/utils/utils";
interface ContextMenuListItemProps {
onClick: () => void;
testId?: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
isDisabled?: boolean;
}
export function ContextMenuListItem({
children,
testId,
onClick,
isDisabled,
}: React.PropsWithChildren<ContextMenuListItemProps>) {
return (
<button
data-testid="context-menu-list-item"
data-testid={testId || "context-menu-list-item"}
type="button"
onClick={onClick}
disabled={isDisabled}
@@ -2,13 +2,19 @@ import React from "react";
import { cn } from "#/utils/utils";
interface ContextMenuProps {
ref?: React.RefObject<HTMLUListElement | null>;
testId?: string;
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLUListElement>["className"];
}
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
({ testId, children, className }, ref) => (
export function ContextMenu({
testId,
children,
className,
ref,
}: ContextMenuProps) {
return (
<ul
data-testid={testId}
ref={ref}
@@ -16,7 +22,5 @@ export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
>
{children}
</ul>
),
);
ContextMenu.displayName = "ContextMenu";
);
}
@@ -0,0 +1,36 @@
import { ModalButton } from "#/components/shared/buttons/modal-button";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
interface ConfirmDeleteModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDeleteModal({
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
return (
<ModalBackdrop>
<ModalBody className="items-start">
<div className="flex flex-col gap-2">
<BaseModalTitle title="Are you sure you want to delete this project?" />
<BaseModalDescription description="All data associated with this project will be lost." />
</div>
<div className="flex flex-col gap-2 w-full">
<ModalButton
onClick={onConfirm}
className="bg-[#4465DB]"
text="Confirm"
/>
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import {
ProjectState,
ConversationStateIndicator,
} from "./conversation-state-indicator";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { EllipsisButton } from "./ellipsis-button";
interface ProjectCardProps {
onClick: () => void;
onDelete: () => void;
onChangeTitle: (title: string) => void;
name: string;
repo: string | null;
lastUpdated: string; // ISO 8601
state?: ProjectState;
}
export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
name,
repo,
lastUpdated,
state = "cold",
}: ProjectCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleBlur = () => {
if (inputRef.current?.value) {
const trimmed = inputRef.current.value.trim();
onChangeTitle(trimmed);
inputRef.current!.value = trimmed;
} else {
// reset the value if it's empty
inputRef.current!.value = name;
}
};
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
event.stopPropagation();
};
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onDelete();
};
return (
<div
data-testid="conversation-card"
onClick={onClick}
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
>
<div className="flex items-center justify-between">
<input
ref={inputRef}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
type="text"
defaultValue={name}
className="text-sm leading-6 font-semibold bg-transparent"
/>
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator state={state} />
<EllipsisButton
onClick={(event) => {
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
{contextMenuVisible && (
<ContextMenu testId="context-menu" className="absolute left-full">
<ContextMenuListItem
testId="delete-button"
onClick={handleDelete}
>
Delete
</ContextMenuListItem>
</ContextMenu>
)}
</div>
</div>
{repo && (
<ConversationRepoLink
repo={repo}
onClick={(e) => e.stopPropagation()}
/>
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
</p>
</div>
);
}
@@ -0,0 +1,128 @@
import React from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { NewConversationButton } from "./new-conversation-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { useEndSession } from "#/hooks/use-end-session";
import { ExitConversationModal } from "./exit-conversation-modal";
interface ConversationPanelProps {
onClose: () => void;
}
export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { conversationId: cid } = useParams();
const navigate = useNavigate();
const location = useLocation();
const endSession = useEndSession();
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
] = React.useState(false);
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: updateConversation } = useUpdateConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation({ conversationId: selectedConversationId });
setConfirmDeleteModalVisible(false);
if (cid === selectedConversationId) {
endSession();
}
}
};
const handleChangeTitle = (
conversationId: string,
oldTitle: string,
newTitle: string,
) => {
if (oldTitle !== newTitle)
updateConversation({
id: conversationId,
conversation: { name: newTitle },
});
};
const handleClickCard = (conversationId: string) => {
navigate(`/conversations/${conversationId}`);
onClose();
};
return (
<div
data-testid="conversation-panel"
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
>
<div className="pt-4 px-4 flex items-center justify-between">
{location.pathname.startsWith("/conversation") && (
<NewConversationButton
onClick={() => setConfirmExitConversationModalVisible(true)}
/>
)}
{isFetching && <LoadingSpinner size="small" />}
</div>
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-danger">{error.message}</p>
</div>
)}
{conversations?.length === 0 && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-400">No conversations found</p>
</div>
)}
{conversations?.map((project) => (
<ConversationCard
key={project.conversation_id}
onClick={() => handleClickCard(project.conversation_id)}
onDelete={() => handleDeleteProject(project.conversation_id)}
onChangeTitle={(title) =>
handleChangeTitle(project.conversation_id, project.name, title)
}
name={project.name}
repo={project.repo}
lastUpdated={project.lastUpdated}
state={project.state}
/>
))}
{confirmDeleteModalVisible && (
<ConfirmDeleteModal
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmDeleteModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {
endSession();
onClose();
}}
onClose={() => setConfirmExitConversationModalVisible(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,21 @@
interface ConversationRepoLinkProps {
repo: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
export function ConversationRepoLink({
repo,
onClick,
}: ConversationRepoLinkProps) {
return (
<a
data-testid="conversation-card-repo"
href={`https://github.com/${repo}`}
target="_blank noopener noreferrer"
onClick={onClick}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{repo}
</a>
);
}
@@ -0,0 +1,40 @@
import ColdIcon from "./state-indicators/cold.svg?react";
import CoolingIcon from "./state-indicators/cooling.svg?react";
import FinishedIcon from "./state-indicators/finished.svg?react";
import RunningIcon from "./state-indicators/running.svg?react";
import WaitingIcon from "./state-indicators/waiting.svg?react";
import WarmIcon from "./state-indicators/warm.svg?react";
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export type ProjectState =
| "cold"
| "cooling"
| "finished"
| "running"
| "waiting"
| "warm";
const INDICATORS: Record<ProjectState, SVGIcon> = {
cold: ColdIcon,
cooling: CoolingIcon,
finished: FinishedIcon,
running: RunningIcon,
waiting: WaitingIcon,
warm: WarmIcon,
};
interface ConversationStateIndicatorProps {
state: ProjectState;
}
export function ConversationStateIndicator({
state,
}: ConversationStateIndicatorProps) {
const StateIcon = INDICATORS[state];
return (
<div data-testid={`${state}-indicator`}>
<StateIcon />
</div>
);
}
@@ -0,0 +1,13 @@
import { FaEllipsisV } from "react-icons/fa";
interface EllipsisButtonProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
return (
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
<FaEllipsisV fill="#a3a3a3" />
</button>
);
}
@@ -0,0 +1,34 @@
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
interface ExitConversationModalProps {
onConfirm: () => void;
onClose: () => void;
}
export function ExitConversationModal({
onConfirm,
onClose,
}: ExitConversationModalProps) {
return (
<ModalBackdrop>
<ModalBody testID="confirm-new-conversation-modal">
<BaseModalTitle title="Creating a new conversation will replace your active conversation" />
<div className="flex w-full gap-2">
<ModalButton
text="Confirm"
onClick={onConfirm}
className="bg-[#C63143] flex-1"
/>
<ModalButton
text="Cancel"
onClick={onClose}
className="bg-neutral-700 flex-1"
/>
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -0,0 +1,16 @@
interface NewConversationButtonProps {
onClick: () => void;
}
export function NewConversationButton({ onClick }: NewConversationButtonProps) {
return (
<button
data-testid="new-conversation-button"
type="button"
onClick={onClick}
className="font-bold bg-[#4465DB] px-2 py-1 rounded"
>
+ New Project
</button>
);
}
@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.87012 2C9.87012 1.44772 9.4224 1 8.87012 1C8.31783 1 7.87012 1.44772 7.87012 2V8C7.87012 8.55228 8.31783 9 8.87012 9C9.4224 9 9.87012 8.55228 9.87012 8V2Z" fill="#A7A9AC"/>
<path d="M10.8698 2.42001V2.56001C10.8698 2.93001 11.0698 3.28001 11.4098 3.43001C13.6798 4.43001 15.2198 6.80001 14.9698 9.48001C14.6998 12.47 12.0998 14.87 9.08979 14.92C5.73979 14.97 2.98979 12.26 2.98979 8.92001C2.98979 6.57001 4.34979 4.54001 6.30979 3.56001C6.63979 3.40001 6.85979 3.08001 6.85979 2.72001V2.55001C6.85979 1.86001 6.13979 1.43001 5.50979 1.73001C2.43979 3.20001 0.449793 6.62001 1.13979 10.41C1.70979 13.57 4.23979 16.14 7.38979 16.76C12.5098 17.76 16.9998 13.86 16.9998 8.92001C16.9998 5.61001 14.9898 2.78001 12.1198 1.56001C11.5298 1.31001 10.8698 1.78001 10.8698 2.42001Z" fill="#A7A9AC"/>
</svg>

After

Width:  |  Height:  |  Size: 904 B

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.87012 2.02002C9.87012 1.46773 9.4224 1.02002 8.87012 1.02002C8.31783 1.02002 7.87012 1.46773 7.87012 2.02002V8.02002C7.87012 8.5723 8.31783 9.02002 8.87012 9.02002C9.4224 9.02002 9.87012 8.5723 9.87012 8.02002V2.02002Z" fill="#EFC818"/>
<path d="M10.8698 2.44003V2.58003C10.8698 2.95003 11.0698 3.30003 11.4098 3.45003C13.6798 4.45003 15.2198 6.82003 14.9698 9.50003C14.6998 12.49 12.0998 14.89 9.08979 14.94C5.73979 14.99 2.98979 12.28 2.98979 8.94003C2.98979 6.59003 4.34979 4.56003 6.30979 3.58003C6.63979 3.42003 6.85979 3.10003 6.85979 2.74003V2.57003C6.85979 1.88003 6.13979 1.45003 5.50979 1.75003C2.43979 3.23003 0.449793 6.64003 1.13979 10.43C1.70979 13.59 4.23979 16.16 7.38979 16.78C12.5098 17.78 16.9998 13.88 16.9998 8.94003C16.9998 5.63003 14.9898 2.80003 12.1198 1.58003C11.5298 1.33003 10.8698 1.80003 10.8698 2.44003Z" fill="#EFC818"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 16.8599C13.4183 16.8599 17 13.2781 17 8.85986C17 4.44159 13.4183 0.859863 9 0.859863C4.58172 0.859863 1 4.44159 1 8.85986C1 13.2781 4.58172 16.8599 9 16.8599Z" fill="#779FD4"/>
<path d="M4.61035 8.43014L7.86035 12.0301L13.3904 6.64014" stroke="#231F20" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.04004 3.10986C12.06 3.10986 14.57 5.34986 14.98 8.25986C15.05 8.74986 15.47 9.10986 15.96 9.10986C16.57 9.10986 17.04 8.56986 16.96 7.96986C16.41 4.08986 13.07 1.10986 9.04004 1.10986C4.62004 1.10986 1.04004 4.68986 1.04004 9.10986C1.04004 13.1399 4.02004 16.4799 7.90004 17.0299C8.50004 17.1199 9.04004 16.6399 9.04004 16.0299C9.04004 15.5399 8.68004 15.1199 8.19004 15.0499C5.28004 14.6399 3.04004 12.1299 3.04004 9.10986C3.04004 5.79986 5.73004 3.10986 9.04004 3.10986Z" fill="#60BB46"/>
<path d="M12.3504 9.11L7.40039 6.25V11.96L12.3504 9.11Z" fill="#60BB46"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.76039 6.99002C8.478 6.99002 9.87039 5.59763 9.87039 3.88002C9.87039 2.16241 8.478 0.77002 6.76039 0.77002C5.04279 0.77002 3.65039 2.16241 3.65039 3.88002C3.65039 5.59763 5.04279 6.99002 6.76039 6.99002Z" fill="#FFE165"/>
<path d="M1.0802 17.0799C1.0802 17.0799 0.610196 11.5499 3.0102 9.67992C4.7902 8.29992 7.3302 9.44992 9.7802 7.95992C11.5802 6.86992 13.6102 4.10992 14.5202 2.49992C14.9302 1.77992 15.9102 1.62992 16.6102 2.05992C17.3802 2.51992 17.6102 3.53992 17.1102 4.28992C16.2302 5.58992 14.1802 8.85992 13.1202 10.3699C10.7602 13.7599 11.4302 17.0799 11.4302 17.0799H1.0702H1.0802Z" fill="#FFE165"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.87012 2.08984C9.87012 1.53756 9.4224 1.08984 8.87012 1.08984C8.31783 1.08984 7.87012 1.53756 7.87012 2.08984V8.08984C7.87012 8.64213 8.31783 9.08984 8.87012 9.08984C9.4224 9.08984 9.87012 8.64213 9.87012 8.08984V2.08984Z" fill="#60BB46"/>
<path d="M10.8702 2.50988V2.64988C10.8702 3.01988 11.0702 3.36988 11.4102 3.51988C13.6802 4.51988 15.2202 6.88988 14.9702 9.56988C14.7002 12.5599 12.1002 14.9599 9.09021 15.0099C5.74021 15.0599 2.99021 12.3499 2.99021 9.00988C2.99021 6.65988 4.35021 4.62988 6.31021 3.64988C6.64021 3.48988 6.86021 3.16988 6.86021 2.80988V2.63988C6.86021 1.94988 6.14021 1.51988 5.51021 1.81988C2.42021 3.30988 0.430214 6.71988 1.12021 10.5199C1.69021 13.6799 4.22021 16.2499 7.37021 16.8699C12.4902 17.8699 16.9802 13.9699 16.9802 9.02988C16.9802 5.71988 14.9702 2.88988 12.1002 1.66988C11.5102 1.41988 10.8502 1.88988 10.8502 2.52988L10.8702 2.50988Z" fill="#60BB46"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

@@ -4,7 +4,7 @@ interface FolderIconProps {
isOpen: boolean;
}
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
export function FolderIcon({ isOpen }: FolderIconProps) {
return isOpen ? (
<FaFolderOpen color="D9D3D0" className="icon" />
) : (
@@ -31,17 +31,6 @@ export function GitHubRepositoriesSuggestionBox({
}
};
if (isGitHubErrorReponse(repositories)) {
return (
<SuggestionBox
title="Error Fetching Repositories"
content={
<p className="text-danger text-center">{repositories.message}</p>
}
/>
);
}
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
return (
@@ -1,5 +1,6 @@
import React from "react";
import posthog from "posthog-js";
import { useTranslation } from "react-i18next";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
@@ -7,6 +8,7 @@ import { ProjectMenuDetails } from "./project-menu-details";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { DownloadModal } from "#/components/shared/download-modal";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -21,6 +23,8 @@ export function ProjectMenuCard({
isConnectedToGitHub,
githubData,
}: ProjectMenuCardProps) {
const { t } = useTranslation();
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
@@ -71,7 +75,7 @@ export function ProjectMenuCard({
<button
type="button"
onClick={toggleMenuVisibility}
aria-label="Open project menu"
aria-label={t(I18nKey.PROJECT_MENU_CARD$OPEN)}
>
<EllipsisH width={36} height={36} />
</button>
@@ -30,7 +30,9 @@ export function ProjectMenuDetailsPlaceholder({
"hover:underline hover:underline-offset-2",
)}
>
{!isConnectedToGitHub ? "Connect to GitHub" : "Connected"}
{!isConnectedToGitHub
? t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB)
: t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED)}
<CloudConnection width={12} height={12} />
</span>
</button>
@@ -1,7 +1,7 @@
import React from "react";
import { useLocation } from "react-router";
import FolderIcon from "#/icons/docs.svg?react";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { UserActions } from "./user-actions";
@@ -13,21 +13,28 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { cn } from "#/utils/utils";
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export function Sidebar() {
const location = useLocation();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
const { settingsAreUpToDate } = useSettings();
const { data: settings, isError: settingsIsError } = useSettings();
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
MULTI_CONVO_UI_IS_ENABLED,
);
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
@@ -54,7 +61,7 @@ export function Sidebar() {
return (
<>
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1 relative">
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
<div className="w-[34px] h-[34px] flex items-center justify-center">
<AllHandsLogoButton onClick={handleClickLogo} />
@@ -70,18 +77,45 @@ export function Sidebar() {
/>
)}
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
{MULTI_CONVO_UI_IS_ENABLED && (
<button
data-testid="toggle-conversation-panel"
type="button"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
className={cn(
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
)}
>
<FolderIcon width={28} height={28} />
</button>
)}
<DocsButton />
<ExitProjectButton
onClick={() => setStartNewProjectModalIsOpen(true)}
/>
</nav>
{conversationPanelIsOpen && (
<div
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
>
<ConversationPanel
onClose={() => setConversationPanelIsOpen(false)}
/>
</div>
)}
</aside>
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{showSettingsModal && (
<SettingsModal onClose={() => setSettingsModalIsOpen(false)} />
)}
{settingsIsError ||
(showSettingsModal && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
))}
{startNewProjectModalIsOpen && (
<ExitProjectConfirmationModal
onClose={() => setStartNewProjectModalIsOpen(false)}
@@ -1,3 +1,4 @@
import { Tooltip } from "@nextui-org/react";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import DefaultUserAvatar from "#/icons/default-user.svg?react";
import { cn } from "#/utils/utils";
@@ -10,7 +11,7 @@ interface UserAvatarProps {
}
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
return (
const buttonContent = (
<button
data-testid="user-avatar"
type="button"
@@ -31,4 +32,10 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
{isLoading && <LoadingSpinner size="small" />}
</button>
);
return (
<Tooltip content="Account settings" closeDelay={100}>
{buttonContent}
</Tooltip>
);
}
@@ -0,0 +1,191 @@
import React, { CSSProperties, JSX, useEffect, useRef, useState } from "react";
import {
VscChevronDown,
VscChevronLeft,
VscChevronRight,
VscChevronUp,
} from "react-icons/vsc";
import { twMerge } from "tailwind-merge";
import { IconButton } from "../shared/buttons/icon-button";
export enum Orientation {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}
enum Collapse {
COLLAPSED = "collapsed",
SPLIT = "split",
FILLED = "filled",
}
type ResizablePanelProps = {
firstChild: React.ReactNode;
firstClassName: string | undefined;
secondChild: React.ReactNode;
secondClassName: string | undefined;
className: string | undefined;
orientation: Orientation;
initialSize: number;
};
export function ResizablePanel({
firstChild,
firstClassName,
secondChild,
secondClassName,
className,
orientation,
initialSize,
}: ResizablePanelProps): JSX.Element {
const [firstSize, setFirstSize] = useState<number>(initialSize);
const [dividerPosition, setDividerPosition] = useState<number | null>(null);
const firstRef = useRef<HTMLDivElement>(null);
const secondRef = useRef<HTMLDivElement>(null);
const [collapse, setCollapse] = useState<Collapse>(Collapse.SPLIT);
const isHorizontal = orientation === Orientation.HORIZONTAL;
useEffect(() => {
if (dividerPosition == null || !firstRef.current) {
return undefined;
}
const getFirstSizeFromEvent = (e: MouseEvent) => {
const position = isHorizontal ? e.clientX : e.clientY;
return firstSize + position - dividerPosition;
};
const onMouseMove = (e: MouseEvent) => {
e.preventDefault();
const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
const { current } = firstRef;
if (current) {
if (isHorizontal) {
current.style.width = newFirstSize;
current.style.minWidth = newFirstSize;
} else {
current.style.height = newFirstSize;
current.style.minHeight = newFirstSize;
}
}
};
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
if (firstRef.current) {
firstRef.current.style.transition = "";
}
if (secondRef.current) {
secondRef.current.style.transition = "";
}
setFirstSize(getFirstSizeFromEvent(e));
setDividerPosition(null);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
}, [dividerPosition, firstSize, orientation]);
const onMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (firstRef.current) {
firstRef.current.style.transition = "none";
}
if (secondRef.current) {
secondRef.current.style.transition = "none";
}
const position = isHorizontal ? e.clientX : e.clientY;
setDividerPosition(position);
};
const getStyleForFirst = () => {
const style: CSSProperties = { overflow: "hidden" };
if (collapse === Collapse.COLLAPSED) {
style.opacity = 0;
style.width = 0;
style.minWidth = 0;
style.height = 0;
style.minHeight = 0;
} else if (collapse === Collapse.SPLIT) {
const firstSizePx = `${firstSize}px`;
if (isHorizontal) {
style.width = firstSizePx;
style.minWidth = firstSizePx;
} else {
style.height = firstSizePx;
style.minHeight = firstSizePx;
}
} else {
style.flexGrow = 1;
}
return style;
};
const getStyleForSecond = () => {
const style: CSSProperties = { overflow: "hidden" };
if (collapse === Collapse.FILLED) {
style.opacity = 0;
style.width = 0;
style.minWidth = 0;
style.height = 0;
style.minHeight = 0;
} else if (collapse === Collapse.SPLIT) {
style.flexGrow = 1;
} else {
style.flexGrow = 1;
}
return style;
};
const onCollapse = () => {
if (collapse === Collapse.SPLIT) {
setCollapse(Collapse.COLLAPSED);
} else {
setCollapse(Collapse.SPLIT);
}
};
const onExpand = () => {
if (collapse === Collapse.SPLIT) {
setCollapse(Collapse.FILLED);
} else {
setCollapse(Collapse.SPLIT);
}
};
return (
<div className={twMerge("flex", !isHorizontal && "flex-col", className)}>
<div
ref={firstRef}
className={twMerge(firstClassName, "transition-all ease-soft-spring")}
style={getStyleForFirst()}
>
{firstChild}
</div>
<div
className={`${isHorizontal ? "cursor-ew-resize w-3 flex-col" : "cursor-ns-resize h-3 flex-row"} shrink-0 flex justify-center items-center`}
onMouseDown={collapse === Collapse.SPLIT ? onMouseDown : undefined}
>
<IconButton
icon={isHorizontal ? <VscChevronLeft /> : <VscChevronUp />}
ariaLabel="Collapse"
onClick={onCollapse}
/>
<IconButton
icon={isHorizontal ? <VscChevronRight /> : <VscChevronDown />}
ariaLabel="Expand"
onClick={onExpand}
/>
</div>
<div
ref={secondRef}
className={twMerge(secondClassName, "transition-all ease-soft-spring")}
style={getStyleForSecond()}
>
{secondChild}
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { TooltipButton } from "./tooltip-button";
interface AllHandsLogoButtonProps {
onClick: () => void;
@@ -6,8 +7,12 @@ interface AllHandsLogoButtonProps {
export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
return (
<button type="button" aria-label="All Hands Logo" onClick={onClick}>
<TooltipButton
tooltip="All Hands AI"
ariaLabel="All Hands Logo"
onClick={onClick}
>
<AllHandsLogo width={34} height={23} />
</button>
</TooltipButton>
);
}
@@ -1,15 +1,14 @@
import DocsIcon from "#/icons/docs.svg?react";
import { TooltipButton } from "./tooltip-button";
export function DocsButton() {
return (
<a
<TooltipButton
tooltip="Documentation"
ariaLabel="Documentation"
href="https://docs.all-hands.dev"
aria-label="Documentation"
target="_blank"
rel="noreferrer noopener"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
>
<DocsIcon width={28} height={28} />
</a>
</TooltipButton>
);
}
@@ -1,4 +1,5 @@
import NewProjectIcon from "#/icons/new-project.svg?react";
import { TooltipButton } from "./tooltip-button";
interface ExitProjectButtonProps {
onClick: () => void;
@@ -6,13 +7,13 @@ interface ExitProjectButtonProps {
export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
return (
<button
data-testid="new-project-button"
type="button"
aria-label="Start new project"
<TooltipButton
tooltip="Start new project"
ariaLabel="Start new project"
onClick={onClick}
testId="new-project-button"
>
<NewProjectIcon width={28} height={28} />
</button>
</TooltipButton>
);
}
@@ -1,9 +1,9 @@
import { Button } from "@nextui-org/react";
import React, { MouseEventHandler, ReactElement } from "react";
import React, { ReactElement } from "react";
export interface IconButtonProps {
icon: ReactElement;
onClick: MouseEventHandler<HTMLButtonElement>;
onClick: () => void;
ariaLabel: string;
testId?: string;
}
@@ -18,7 +18,7 @@ export function IconButton({
<Button
type="button"
variant="flat"
onClick={onClick}
onPress={onClick}
className="cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[20px] h-[20px]"
aria-label={ariaLabel}
data-testid={testId}
@@ -1,4 +1,5 @@
import CogTooth from "#/assets/cog-tooth";
import { TooltipButton } from "./tooltip-button";
interface SettingsButtonProps {
onClick: () => void;
@@ -6,13 +7,13 @@ interface SettingsButtonProps {
export function SettingsButton({ onClick }: SettingsButtonProps) {
return (
<button
type="button"
aria-label="Settings"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
<TooltipButton
testId="settings-button"
tooltip="Settings"
ariaLabel="Settings"
onClick={onClick}
>
<CogTooth />
</button>
</TooltipButton>
);
}
@@ -0,0 +1,52 @@
import { Tooltip } from "@nextui-org/react";
import { ReactNode } from "react";
interface TooltipButtonProps {
children: ReactNode;
tooltip: string;
onClick?: () => void;
href?: string;
ariaLabel: string;
testId?: string;
}
export function TooltipButton({
children,
tooltip,
onClick,
href,
ariaLabel,
testId,
}: TooltipButtonProps) {
const buttonContent = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
onClick={onClick}
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
>
{children}
</button>
);
const content = href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="w-8 h-8 rounded-full hover:opacity-80 flex items-center justify-center"
aria-label={ariaLabel}
>
{children}
</a>
) : (
buttonContent
);
return (
<Tooltip content={tooltip} closeDelay={100}>
{content}
</Tooltip>
);
}
@@ -1,4 +1,6 @@
import toast, { Toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface ErrorToastProps {
id: Toast["id"];
@@ -6,6 +8,8 @@ interface ErrorToastProps {
}
export function ErrorToast({ id, error }: ErrorToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between w-full h-full">
<span>{error}</span>
@@ -14,7 +18,7 @@ export function ErrorToast({ id, error }: ErrorToastProps) {
onClick={() => toast.dismiss(id)}
className="bg-neutral-500 px-1 rounded h-full"
>
Close
{t(I18nKey.ERROR_TOAST$CLOSE_BUTTON_LABEL)}
</button>
</div>
);
@@ -1,27 +1,37 @@
import { Input } from "@nextui-org/react";
import { Input, Tooltip } from "@nextui-org/react";
import { useTranslation } from "react-i18next";
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
interface APIKeyInputProps {
isDisabled: boolean;
defaultValue: string;
isSet: boolean;
}
export function APIKeyInput({ isDisabled, defaultValue }: APIKeyInputProps) {
export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
const { t } = useTranslation();
return (
<fieldset data-testid="api-key-input" className="flex flex-col gap-2">
<label htmlFor="api-key" className="font-[500] text-[#A3A3A3] text-xs">
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
</label>
<Tooltip content={isSet ? "API Key is set" : "API Key is not set"}>
<label
htmlFor="api-key"
className="font-[500] text-[#A3A3A3] text-xs flex items-center gap-1 self-start"
>
{isSet && <FaCheckCircle className="text-[#00D1B2] inline-block" />}
{!isSet && (
<FaExclamationCircle className="text-[#FF3860] inline-block" />
)}
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
</label>
</Tooltip>
<Input
isDisabled={isDisabled}
id="api-key"
name="api-key"
aria-label="API Key"
type="password"
defaultValue={defaultValue}
defaultValue=""
classNames={{
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
}}
@@ -8,12 +8,12 @@ import { ModalBody } from "../modal-body";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalButton } from "../../buttons/modal-button";
import { CustomInput } from "../../custom-input";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface AccountSettingsFormProps {
onClose: () => void;
@@ -30,7 +30,7 @@ export function AccountSettingsForm({
}: AccountSettingsFormProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { data: config } = useConfig();
const { saveSettings } = useSettings();
const { mutate: saveSettings } = useSaveSettings();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
@@ -1,5 +1,5 @@
import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useSettings } from "#/hooks/query/use-settings";
import { ModalBackdrop } from "../modal-backdrop";
import { AccountSettingsForm } from "./account-settings-form";
@@ -9,7 +9,7 @@ interface AccountSettingsModalProps {
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
const user = useGitHubUser();
const { settings } = useSettings();
const { data: settings } = useSettings();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");
@@ -23,7 +23,7 @@ export function FooterContent({ actions, closeModal }: FooterContentProps) {
key={label}
type="button"
isDisabled={isDisabled}
onClick={() => {
onPress={() => {
action();
if (closeAfterAction) closeModal();
}}
@@ -35,14 +35,20 @@ export function BaseModalDescription({
}
interface BaseModalProps {
testId?: string;
title: string;
description: string;
buttons: ButtonConfig[];
}
export function BaseModal({ title, description, buttons }: BaseModalProps) {
export function BaseModal({
testId,
title,
description,
buttons,
}: BaseModalProps) {
return (
<ModalBody>
<ModalBody testID={testId}>
<div className="flex flex-col gap-2 self-start">
<BaseModalTitle title={title} />
<BaseModalDescription description={description} />
@@ -1,6 +1,8 @@
import { BaseModal } from "./base-modal";
interface DangerModalProps {
testId?: string;
title: string;
description: string;
@@ -10,9 +12,15 @@ interface DangerModalProps {
};
}
export function DangerModal({ title, description, buttons }: DangerModalProps) {
export function DangerModal({
testId,
title,
description,
buttons,
}: DangerModalProps) {
return (
<BaseModal
testId={testId}
title={title}
description={description}
buttons={[
@@ -4,7 +4,7 @@ interface InvariantLogoIconProps {
className?: string;
}
function InvariantLogoIcon({ className }: InvariantLogoIconProps): JSX.Element {
function InvariantLogoIcon({ className }: InvariantLogoIconProps) {
return (
<svg
width="39"
@@ -24,7 +24,7 @@ import { useGetTraces } from "#/hooks/query/use-get-traces";
type SectionType = "logs" | "policy" | "settings";
function SecurityInvariant(): JSX.Element {
function SecurityInvariant() {
const { t } = useTranslation();
const { logs } = useSelector((state: RootState) => state.securityAnalyzer);
@@ -122,12 +122,12 @@ function SecurityInvariant(): JSX.Element {
[],
);
const sections: { [key in SectionType]: JSX.Element } = {
const sections: Record<SectionType, React.ReactNode> = {
logs: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
<Button onClick={() => exportTraces()} className="bg-neutral-700">
<Button onPress={() => exportTraces()} className="bg-neutral-700">
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
</Button>
</div>
@@ -162,7 +162,7 @@ function SecurityInvariant(): JSX.Element {
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
<Button
className="bg-neutral-700"
onClick={() => updatePolicy({ policy })}
onPress={() => updatePolicy({ policy })}
>
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
</Button>
@@ -184,7 +184,7 @@ function SecurityInvariant(): JSX.Element {
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
<Button
className="bg-neutral-700"
onClick={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
onPress={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
>
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
</Button>

Some files were not shown because too many files have changed in this diff Show More