Compare commits

..

19 Commits

Author SHA1 Message Date
openhands 31341cc7f1 Add debugging and data-testid for tasks tab visibility troubleshooting
- Add console.log statements to help debug tab rendering
- Add data-testid='tasks-tab' to make tasks tab easier to identify
- Update ConversationTabNav to accept data-testid prop
2025-09-19 20:26:01 +00:00
openhands aaa2dbe45e Fix Unicode regex pattern in task parsing
Add 'u' flag to regex to properly handle Unicode emoji characters in task status parsing.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-19 20:03:32 +00:00
Hiep Le b7765ba3f7 refactor(frontend): fix typecheck (#11037) 2025-09-19 13:43:00 -04:00
Hiep Le b89f2e51e4 refactor(frontend): migration of metrics-slice.ts to zustand (#11018) 2025-09-19 23:52:21 +07:00
mamoodi e09f93aa75 Release 0.57.0 (#10981)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-09-19 12:40:56 -04:00
Hiep Le 9f529b105a refactor(frontend): migration of command-slice.ts to zustand (#11003) 2025-09-19 23:33:59 +07:00
Graham Neubig 89e3d2a867 Improve OpenHands provider pricing documentation (#10974)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-20 00:22:44 +08:00
Hiep Le a7b9a4f291 refactor(frontend): migration of status-slice.ts to zustand (#11017) 2025-09-19 22:27:55 +07:00
Hiep Le 88cd16ae21 refactor(frontend): migration of initial-query-slice.ts to zustand (#11020) 2025-09-19 22:27:20 +07:00
Hiep Le a8a3e9e604 refactor(frontend): remove the code-slice.ts file (#11021) 2025-09-19 21:22:29 +07:00
Hiep Le 0061bcc0b0 refactor(frontend): custom chat input (#10984) 2025-09-19 21:06:18 +07:00
Hiep Le 9c9fa780b0 refactor(frontend): task tracking observation content (#11002) 2025-09-19 20:03:05 +07:00
Alona 569ac16163 Improve token refresh error logging (#11026) 2025-09-19 14:18:38 +07:00
Robert Brennan 46f7738f41 Update Python packages to latest versions (#11023)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 19:52:46 +00:00
Rohit Malhotra 3f3669dd34 Hotfix: rm model choice override (#11022) 2025-09-18 14:40:06 -04:00
sp.wack cd65645eea Hide Tavily search API key help text in SaaS mode (#11014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 16:40:29 +00:00
Robert Brennan 8e88a7a277 fix: resolve critical and high CVEs in enterprise Docker image (#10987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 11:25:33 -04:00
Hiep Le b393d52439 refactor(frontend): conversation main (#10985) 2025-09-18 20:23:13 +07:00
Hiep Le faeec48365 refactor(frontend): conversation card (#10986) 2025-09-18 20:22:59 +07:00
98 changed files with 2430 additions and 1556 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.57-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.57
```
</details>
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.57
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.57
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.57-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +122,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--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.56 \
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
python -m openhands.cli.entry --override-cli-mode true
```
+2 -2
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--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.56 \
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.57
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.57
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+16 -2
View File
@@ -30,6 +30,20 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
## Pricing
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
Pricing follows official API provider rates. Below are the current pricing details for OpenHands models:
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens) | Max Input Tokens | Max Output Tokens |
|-------|----------------------------|-----------------------------------|------------------------------|------------------|-------------------|
| claude-opus-4-20250514 | $15.00 | $1.50 | $75.00 | 200,000 | 32,000 |
| claude-sonnet-4-20250514 | $3.00 | $0.30 | $15.00 | 200,000 | 64,000 |
| devstral-medium-2507 | $0.40 | N/A | $2.00 | 128,000 | 128,000 |
| devstral-small-2505 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
| devstral-small-2507 | $0.10 | N/A | $0.30 | 128,000 | 128,000 |
| gemini-2.5-pro | $1.25 | $0.31 | $10.00 | 1,048,576 | 65,535 |
| gpt-5-2025-08-07 | $1.25 | $0.125 | $10.00 | 400,000 | 128,000 |
| gpt-5-mini-2025-08-07 | $0.25 | $0.025 | $2.00 | 400,000 | 128,000 |
| o3 | $2.00 | $0.50 | $8.00 | 200,000 | 100,000 |
| o4-mini | $1.10 | $0.28 | $4.40 | 200,000 | 100,000 |
| qwen3-coder-480b | $0.40 | N/A | $1.60 | N/A | N/A |
**Note:** Cached input tokens are charged at a reduced rate when the same content is reused across requests. Models that don't support prompt caching show "N/A" for cached input cost.
+3 -3
View File
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.56
docker.all-hands.dev/all-hands-ai/openhands:0.57
```
</Accordion>
+16 -2
View File
@@ -7,14 +7,28 @@ LABEL com.datadoghq.tags.service="deploy"
LABEL com.datadoghq.tags.env="${DD_ENV}"
# Install Node.js v20+ and npm (which includes npx)
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
apt-get clean
# Apply security updates for packages with available fixes
apt-get upgrade -y \
libc-bin \
libc6 \
libgnutls30 \
libsqlite3-0 \
perl-base && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy
# Install Python packages with security fixes
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
# Update packages with known CVE fixes
pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
WORKDIR /app
COPY enterprise .
@@ -2,7 +2,6 @@ from experiments.constants import (
ENABLE_EXPERIMENT_MANAGER,
)
from experiments.experiment_versions import (
handle_claude4_vs_gpt5_experiment,
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
@@ -44,9 +43,6 @@ class SaaSExperimentManager(ExperimentManager):
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_claude4_vs_gpt5_experiment(
user_id, conversation_id, conversation_settings
)
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
)
@@ -357,69 +357,6 @@ describe("ConversationCard", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("should show display cost button only when showOptions is true", async () => {
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
showOptions
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
});
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
await user.click(displayCostButton);
// Verify if metrics modal is displayed by checking for the modal content
expect(screen.getByTestId("metrics-modal")).toBeInTheDocument();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
@@ -1,10 +1,9 @@
import { screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientConfig } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import { renderWithQueryAndI18n } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
@@ -18,16 +17,7 @@ describe("ConversationPanel", () => {
},
]);
const renderConversationPanel = (config?: QueryClientConfig) =>
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
},
});
const renderConversationPanel = () => renderWithQueryAndI18n(<RouterStub />);
beforeAll(() => {
vi.mock("react-router", async (importOriginal) => ({
@@ -297,15 +287,7 @@ describe("ConversationPanel", () => {
},
]);
renderWithProviders(<MyRouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
},
});
renderWithQueryAndI18n(<MyRouterStub />);
const toggleButton = screen.getByText("Toggle");
@@ -57,11 +57,6 @@ describe("MicroagentManagement", () => {
const renderMicroagentManagement = (config?: QueryClientConfig) =>
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
addMicroagentModalVisible: false,
updateMicroagentModalVisible: false,
@@ -1351,11 +1346,6 @@ describe("MicroagentManagement", () => {
// Render with modal already visible in Redux state
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: null,
addMicroagentModalVisible: true, // Start with modal visible
@@ -1646,11 +1636,6 @@ describe("MicroagentManagement", () => {
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
renderWithProviders(<MicroagentManagementMain />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
addMicroagentModalVisible: false,
selectedRepository: {
@@ -1998,11 +1983,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible in Redux state
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2037,11 +2017,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible and selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2075,11 +2050,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible and selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2118,11 +2088,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible and selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2174,11 +2139,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2225,11 +2185,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2279,11 +2234,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible but no microagent data
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: null,
addMicroagentModalVisible: false,
@@ -2325,11 +2275,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2374,11 +2319,6 @@ describe("MicroagentManagement", () => {
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForUpdate,
@@ -2561,11 +2501,6 @@ describe("MicroagentManagement", () => {
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForLearn,
@@ -2601,11 +2536,6 @@ describe("MicroagentManagement", () => {
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForLearn,
@@ -2658,11 +2588,6 @@ describe("MicroagentManagement", () => {
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForLearn,
@@ -2718,11 +2643,6 @@ describe("MicroagentManagement", () => {
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForLearn,
@@ -2776,11 +2696,6 @@ describe("MicroagentManagement", () => {
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: {
microagent: mockMicroagentForLearn,
@@ -1,17 +1,14 @@
import { act, screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { vi, describe, afterEach, it, expect } from "vitest";
import { Command, appendInput, appendOutput } from "#/state/command-slice";
import { Command, useCommandStore } from "#/state/command-store";
import Terminal from "#/components/features/terminal/terminal";
const renderTerminal = (commands: Command[] = []) =>
renderWithProviders(<Terminal />, {
preloadedState: {
cmd: {
commands,
},
},
});
const renderTerminal = (commands: Command[] = []) => {
// Set initial commands in Zustand store
useCommandStore.setState({ commands });
return renderWithProviders(<Terminal />);
};
describe.skip("Terminal", () => {
global.ResizeObserver = vi.fn().mockImplementation(() => ({
@@ -58,25 +55,25 @@ describe.skip("Terminal", () => {
});
it("should write commands to the terminal", () => {
const { store } = renderTerminal();
renderTerminal();
act(() => {
store.dispatch(appendInput("echo Hello"));
store.dispatch(appendOutput("Hello"));
useCommandStore.getState().appendInput("echo Hello");
useCommandStore.getState().appendOutput("Hello");
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo Hello");
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
act(() => {
store.dispatch(appendInput("echo World"));
useCommandStore.getState().appendInput("echo World");
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo World");
});
it("should load and write commands to the terminal", () => {
const { store } = renderTerminal([
renderTerminal([
{ type: "input", content: "echo Hello" },
{ type: "output", content: "Hello" },
]);
@@ -85,17 +82,17 @@ describe.skip("Terminal", () => {
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "Hello");
act(() => {
store.dispatch(appendInput("echo Hello"));
useCommandStore.getState().appendInput("echo Hello");
});
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(3, "echo Hello");
});
it("should end the line with a dollar sign after writing a command", () => {
const { store } = renderTerminal();
renderTerminal();
act(() => {
store.dispatch(appendInput("echo Hello"));
useCommandStore.getState().appendInput("echo Hello");
});
expect(mockTerminal.writeln).toHaveBeenCalledWith("echo Hello");
+9 -17
View File
@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach } from "node:test";
import { useTerminal } from "#/hooks/use-terminal";
import { Command } from "#/state/command-slice";
import { Command, useCommandStore } from "#/state/command-store";
import { AgentState } from "#/types/agent-state";
import { renderWithProviders } from "../../test-utils";
@@ -19,10 +19,10 @@ interface TestTerminalComponentProps {
commands: Command[];
}
function TestTerminalComponent({
commands,
}: TestTerminalComponentProps) {
const ref = useTerminal({ commands });
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
// Set commands in Zustand store
useCommandStore.setState({ commands });
const ref = useTerminal();
return <div ref={ref} />;
}
@@ -60,7 +60,6 @@ describe("useTerminal", () => {
renderWithProviders(<TestTerminalComponent commands={[]} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
cmd: { commands: [] },
},
});
});
@@ -74,7 +73,6 @@ describe("useTerminal", () => {
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
cmd: { commands },
},
});
@@ -94,17 +92,11 @@ describe("useTerminal", () => {
{ content: secret, type: "output" },
];
renderWithProviders(
<TestTerminalComponent
commands={commands}
/>,
{
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
cmd: { commands },
},
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
},
);
});
// This test is no longer relevant as secrets filtering has been removed
});
+15 -11
View File
@@ -1,20 +1,24 @@
import { describe, it, expect } from "vitest";
import store from "../src/store";
import {
setInitialPrompt,
clearInitialPrompt,
} from "../src/state/initial-query-slice";
import { describe, it, expect, beforeEach } from "vitest";
import { useInitialQueryStore } from "../src/stores/initial-query-store";
describe("Initial Query Behavior", () => {
it("should clear initial query when clearInitialPrompt is dispatched", () => {
beforeEach(() => {
// Reset the store before each test
useInitialQueryStore.getState().reset();
});
it("should clear initial query when clearInitialPrompt is called", () => {
const { setInitialPrompt, clearInitialPrompt, initialPrompt } =
useInitialQueryStore.getState();
// Set up initial query in the store
store.dispatch(setInitialPrompt("test query"));
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
setInitialPrompt("test query");
expect(useInitialQueryStore.getState().initialPrompt).toBe("test query");
// Clear the initial query
store.dispatch(clearInitialPrompt());
clearInitialPrompt();
// Verify initial query is cleared
expect(store.getState().initialQuery.initialPrompt).toBeNull();
expect(useInitialQueryStore.getState().initialPrompt).toBeNull();
});
});
+24 -6
View File
@@ -13,14 +13,26 @@ vi.mock("#/store", () => ({
},
}));
vi.mock("#/state/command-slice", () => ({
appendInput: mockAppendInput,
vi.mock("#/state/command-store", () => ({
useCommandStore: {
getState: () => ({
appendInput: mockAppendInput,
}),
},
}));
vi.mock("#/state/jupyter-slice", () => ({
appendJupyterInput: mockAppendJupyterInput,
}));
vi.mock("#/state/metrics-slice", () => ({
setMetrics: vi.fn(),
}));
vi.mock("#/state/security-analyzer-slice", () => ({
appendSecurityAnalyzerInput: vi.fn(),
}));
describe("handleActionMessage", () => {
beforeEach(() => {
// Clear all mocks before each test
@@ -45,7 +57,8 @@ describe("handleActionMessage", () => {
handleActionMessage(runAction);
// Check that appendInput was called with the command
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
@@ -59,7 +72,8 @@ describe("handleActionMessage", () => {
args: {
code: "print('Hello from Jupyter!')",
},
message: "Running Python code interactively: print('Hello from Jupyter!')",
message:
"Running Python code interactively: print('Hello from Jupyter!')",
timestamp: "2023-01-01T00:00:00Z",
};
@@ -67,7 +81,9 @@ describe("handleActionMessage", () => {
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
expect(mockDispatch).toHaveBeenCalledWith(
mockAppendJupyterInput("print('Hello from Jupyter!')"),
);
expect(mockAppendInput).not.toHaveBeenCalled();
});
@@ -89,7 +105,9 @@ describe("handleActionMessage", () => {
// Handle the action
handleActionMessage(hiddenAction);
// Check that nothing was dispatched
// Check that nothing was dispatched or called
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockAppendInput).not.toHaveBeenCalled();
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
});
+33 -3
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.56.0",
"version": "0.57.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.56.0",
"version": "0.57.0",
"dependencies": {
"@heroui/react": "^2.8.3",
"@heroui/use-infinite-scroll": "^2.2.11",
@@ -59,7 +59,8 @@
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.1.4",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
"ws": "^8.18.2",
"zustand": "^5.0.8"
},
"devDependencies": {
"@babel/parser": "^7.28.3",
@@ -18326,6 +18327,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.56.0",
"version": "0.57.0",
"private": true,
"type": "module",
"engines": {
@@ -58,7 +58,8 @@
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.1.4",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
"ws": "^8.18.2",
"zustand": "^5.0.8"
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
@@ -18,6 +18,7 @@ import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -67,9 +68,7 @@ export function ChatInterface() {
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUploadFiles();
@@ -0,0 +1,34 @@
import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onStop?: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleStop,
handleResumeAgent,
onStop,
}: ChatInputActionsProps) {
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
</div>
<AgentStatus
handleStop={() => handleStop(onStop)}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
/>
</div>
);
}
@@ -0,0 +1,89 @@
import React from "react";
import { ConversationStatus } from "#/types/conversation-status";
import { DragOver } from "../drag-over";
import { UploadedFiles } from "../uploaded-files";
import { ChatInputRow } from "./chat-input-row";
import { ChatInputActions } from "./chat-input-actions";
interface ChatInputContainerProps {
chatContainerRef: React.RefObject<HTMLDivElement | null>;
isDragOver: boolean;
disabled: boolean;
showButton: boolean;
buttonClassName: string;
conversationStatus: ConversationStatus | null;
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
onDrop: (e: React.DragEvent, isDisabled: boolean) => void;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
onStop?: () => void;
}
export function ChatInputContainer({
chatContainerRef,
isDragOver,
disabled,
showButton,
buttonClassName,
conversationStatus,
chatInputRef,
handleFileIconClick,
handleSubmit,
handleStop,
handleResumeAgent,
onDragOver,
onDragLeave,
onDrop,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
onStop,
}: ChatInputContainerProps) {
return (
<div
ref={chatContainerRef}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
onDragOver={(e) => onDragOver(e, disabled)}
onDragLeave={(e) => onDragLeave(e, disabled)}
onDrop={(e) => onDrop(e, disabled)}
>
{/* Drag Over UI */}
{isDragOver && <DragOver />}
<UploadedFiles />
<ChatInputRow
chatInputRef={chatInputRef}
disabled={disabled}
showButton={showButton}
buttonClassName={buttonClassName}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onStop={onStop}
/>
</div>
);
}
@@ -0,0 +1,44 @@
import React from "react";
import { useTranslation } from "react-i18next";
interface ChatInputFieldProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
}
export function ChatInputField({
chatInputRef,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
}: ChatInputFieldProps) {
const { t } = useTranslation();
return (
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
data-name="Text & caret"
>
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
<div
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-testid="chat-input"
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import React from "react";
import { cn } from "#/utils/utils";
interface ChatInputGripProps {
gripRef: React.RefObject<HTMLDivElement | null>;
isGripVisible: boolean;
handleTopEdgeClick: (e: React.MouseEvent) => void;
handleGripMouseDown: (e: React.MouseEvent) => void;
handleGripTouchStart: (e: React.TouchEvent) => void;
}
export function ChatInputGrip({
gripRef,
isGripVisible,
handleTopEdgeClick,
handleGripMouseDown,
handleGripTouchStart,
}: ChatInputGripProps) {
return (
<div
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
id="resize-grip"
onClick={handleTopEdgeClick}
>
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
<div
ref={gripRef}
className={cn(
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
isGripVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
onMouseDown={handleGripMouseDown}
onTouchStart={handleGripTouchStart}
style={{ userSelect: "none" }}
/>
</div>
);
}
@@ -0,0 +1,62 @@
import React from "react";
import { cn } from "#/utils/utils";
import { ChatAddFileButton } from "../chat-add-file-button";
import { ChatSendButton } from "../chat-send-button";
import { ChatInputField } from "./chat-input-field";
interface ChatInputRowProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
disabled: boolean;
showButton: boolean;
buttonClassName: string;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
}
export function ChatInputRow({
chatInputRef,
disabled,
showButton,
buttonClassName,
handleFileIconClick,
handleSubmit,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
}: ChatInputRowProps) {
return (
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
<ChatAddFileButton
disabled={disabled}
handleFileIconClick={() => handleFileIconClick(disabled)}
/>
<ChatInputField
chatInputRef={chatInputRef}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
{/* Send Button */}
{showButton && (
<ChatSendButton
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
handleSubmit={handleSubmit}
disabled={disabled}
/>
)}
</div>
);
}
@@ -0,0 +1,23 @@
import React from "react";
interface HiddenFileInputProps {
fileInputRef: React.RefObject<HTMLInputElement | null>;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export function HiddenFileInput({
fileInputRef,
onChange,
}: HiddenFileInputProps) {
return (
<input
type="file"
ref={fileInputRef}
multiple
accept="*/*"
style={{ display: "none" }}
onChange={onChange}
data-testid="upload-image-input"
/>
);
}
@@ -1,25 +1,20 @@
import React, { useRef, useCallback, useState, useEffect } from "react";
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { ConversationStatus } from "#/types/conversation-status";
import { ChatSendButton } from "./chat-send-button";
import { ChatAddFileButton } from "./chat-add-file-button";
import { cn, isMobileDevice } from "#/utils/utils";
import { useAutoResize } from "#/hooks/use-auto-resize";
import { DragOver } from "./drag-over";
import { UploadedFiles } from "./uploaded-files";
import { Tools } from "../controls/tools";
import {
clearAllFiles,
setShouldHideSuggestions,
setSubmittedMessage,
setMessageToSend,
setIsRightPanelShown,
} from "#/state/conversation-slice";
import { CHAT_INPUT } from "#/utils/constants";
import { RootState } from "#/store";
import { ServerStatus } from "../controls/server-status";
import { AgentStatus } from "../controls/agent-status";
import { useChatInputLogic } from "#/hooks/chat/use-chat-input-logic";
import { useFileHandling } from "#/hooks/chat/use-file-handling";
import { useGripResize } from "#/hooks/chat/use-grip-resize";
import { useChatInputEvents } from "#/hooks/chat/use-chat-input-events";
import { useChatSubmission } from "#/hooks/chat/use-chat-submission";
import { ChatInputGrip } from "./components/chat-input-grip";
import { ChatInputContainer } from "./components/chat-input-container";
import { HiddenFileInput } from "./components/hidden-file-input";
export interface CustomChatInputProps {
disabled?: boolean;
@@ -46,13 +41,9 @@ export function CustomChatInput({
className = "",
buttonClassName = "",
}: CustomChatInputProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isGripVisible, setIsGripVisible] = useState(false);
const { messageToSend, submittedMessage, hasRightPanelToggled } = useSelector(
const { submittedMessage } = useSelector(
(state: RootState) => state.conversation,
);
const dispatch = useDispatch();
// Disable input when conversation is stopped
@@ -68,87 +59,55 @@ export function CustomChatInput({
dispatch(setSubmittedMessage(null));
}, [submittedMessage, disabled, onSubmit, dispatch]);
const { t } = useTranslation();
const chatInputRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const gripRef = useRef<HTMLDivElement>(null);
// Save current input value when drawer state changes
useEffect(() => {
if (chatInputRef.current) {
const currentText = chatInputRef.current?.innerText || "";
// Dispatch to save current input value when drawer state changes
dispatch(setMessageToSend(currentText));
dispatch(setIsRightPanelShown(hasRightPanelToggled));
}
}, [hasRightPanelToggled, dispatch]);
// Helper function to check if contentEditable is truly empty
const isContentEmpty = useCallback((): boolean => {
if (!chatInputRef.current) return true;
const text =
chatInputRef.current.innerText || chatInputRef.current.textContent || "";
return text.trim() === "";
}, []);
// Helper function to properly clear contentEditable for placeholder display
const clearEmptyContent = useCallback((): void => {
if (chatInputRef.current && isContentEmpty()) {
chatInputRef.current.innerHTML = "";
chatInputRef.current.textContent = "";
}
}, [isContentEmpty]);
// Drag state management callbacks
const handleDragStart = useCallback(() => {
// Keep grip visible during drag by adding a CSS class
if (gripRef.current) {
gripRef.current.classList.add("opacity-100");
gripRef.current.classList.remove("opacity-0");
}
}, []);
const handleDragEnd = useCallback(() => {
// Restore hover-based visibility
if (gripRef.current) {
gripRef.current.classList.remove("opacity-100");
gripRef.current.classList.add("opacity-0");
}
}, []);
// Handle click on top edge area to toggle grip visibility
const handleTopEdgeClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsGripVisible((prev) => !prev);
};
// Callback to handle height changes and manage suggestions visibility
const handleHeightChange = useCallback(
(height: number) => {
// Hide suggestions when input height exceeds the threshold
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
},
[dispatch],
);
// Use the auto-resize hook with height change callback
// Custom hooks
const {
chatInputRef,
messageToSend,
checkIsContentEmpty,
clearEmptyContentHandler,
} = useChatInputLogic();
const {
fileInputRef,
chatContainerRef,
isDragOver,
handleFileIconClick,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
} = useFileHandling(onFilesPaste);
const {
gripRef,
isGripVisible,
handleTopEdgeClick,
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
} = useAutoResize(chatInputRef, {
minHeight: 20,
maxHeight: 400,
onHeightChange: handleHeightChange,
onGripDragStart: handleDragStart,
onGripDragEnd: handleDragEnd,
value: messageToSend ?? undefined,
enableManualResize: true,
});
} = useGripResize(
chatInputRef as React.RefObject<HTMLDivElement | null>,
messageToSend,
);
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
chatInputRef as React.RefObject<HTMLDivElement | null>,
fileInputRef as React.RefObject<HTMLInputElement | null>,
smartResize,
onSubmit,
);
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
useChatInputEvents(
chatInputRef as React.RefObject<HTMLDivElement | null>,
smartResize,
increaseHeightForEmptyContent,
checkIsContentEmpty,
clearEmptyContentHandler,
onFocus,
onBlur,
);
// Cleanup: reset suggestions visibility when component unmounts
useEffect(
@@ -159,283 +118,46 @@ export function CustomChatInput({
[dispatch],
);
// Function to add files and notify parent
const addFiles = useCallback(
(files: File[]) => {
// Call onFilesPaste if provided with the new files
if (onFilesPaste && files.length > 0) {
onFilesPaste(files);
}
},
[onFilesPaste],
);
// File icon click handler
const handleFileIconClick = () => {
if (!isDisabled && fileInputRef.current) {
fileInputRef.current.click();
}
};
// File input change handler
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
addFiles(files);
};
// Drag and drop event handlers
const handleDragOver = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
// Only remove drag-over class if we're leaving the container entirely
if (!chatContainerRef.current?.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
};
// Send button click handler
const handleSubmit = () => {
const message = chatInputRef.current?.innerText || "";
if (message.trim()) {
onSubmit(message);
// Clear the input
if (chatInputRef.current) {
chatInputRef.current.textContent = "";
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Reset height and show suggestions again
smartResize();
}
};
// Resume agent button click handler
const handleResumeAgent = () => {
const message = chatInputRef.current?.innerText || "continue";
onSubmit(message.trim());
// Clear the input
if (chatInputRef.current) {
chatInputRef.current.textContent = "";
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Reset height and show suggestions again
smartResize();
};
// Handle stop button click
const handleStop = () => {
if (onStop) {
onStop();
}
};
// Handle input events
const handleInput = () => {
smartResize();
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent();
}
// Ensure cursor stays visible when content is scrollable
if (!chatInputRef.current) {
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (
!range.getBoundingClientRect ||
!chatInputRef.current.getBoundingClientRect
) {
return;
}
const rect = range.getBoundingClientRect();
const inputRect = chatInputRef.current.getBoundingClientRect();
// If cursor is below the visible area, scroll to show it
if (rect.bottom > inputRect.bottom) {
chatInputRef.current.scrollTop =
chatInputRef.current.scrollHeight - chatInputRef.current.clientHeight;
}
};
// Handle paste events to clean up formatting
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
// Get plain text from clipboard
const text = e.clipboardData.getData("text/plain");
// Insert plain text
document.execCommand("insertText", false, text);
// Trigger resize
setTimeout(smartResize, 0);
};
// Handle key events
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "Enter") {
return;
}
if (isContentEmpty()) {
e.preventDefault();
increaseHeightForEmptyContent();
return;
}
// Original submit logic - only for desktop without shift key
if (!isMobileDevice() && !e.shiftKey && !disabled) {
e.preventDefault();
handleSubmit();
}
};
// Handle blur events to ensure placeholder shows when empty
const handleBlur = () => {
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent();
}
// Call the original onBlur callback if provided
if (onBlur) {
onBlur();
}
};
return (
<div className={`w-full ${className}`}>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
multiple
accept="*/*"
style={{ display: "none" }}
<HiddenFileInput
fileInputRef={fileInputRef}
onChange={handleFileInputChange}
data-testid="upload-image-input"
/>
{/* Container with grip */}
<div className="relative w-full">
{/* Top edge hover area - invisible area that triggers grip visibility */}
<div
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
id="resize-grip"
onClick={handleTopEdgeClick}
>
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
<div
ref={gripRef}
className={cn(
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
isGripVisible
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onMouseDown={handleGripMouseDown}
onTouchStart={handleGripTouchStart}
style={{ userSelect: "none" }}
/>
</div>
<ChatInputGrip
gripRef={gripRef}
isGripVisible={isGripVisible}
handleTopEdgeClick={handleTopEdgeClick}
handleGripMouseDown={handleGripMouseDown}
handleGripTouchStart={handleGripTouchStart}
/>
{/* Chat Input Component */}
<div
ref={chatContainerRef}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
<ChatInputContainer
chatContainerRef={chatContainerRef}
isDragOver={isDragOver}
disabled={isDisabled}
showButton={showButton}
buttonClassName={buttonClassName}
conversationStatus={conversationStatus}
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Over UI */}
{isDragOver && <DragOver />}
<UploadedFiles />
{/* Main Input Row */}
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
<ChatAddFileButton
disabled={disabled}
handleFileIconClick={handleFileIconClick}
/>
{/* Chat Input Area */}
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
data-name="Text & caret"
>
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
<div
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-testid="chat-input"
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={handleBlur}
/>
</div>
</div>
</div>
{/* Send Button */}
{showButton && (
<ChatSendButton
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
handleSubmit={handleSubmit}
disabled={disabled}
/>
)}
</div>
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
</div>
<AgentStatus
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
/>
</div>
</div>
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
onFocus={handleFocus}
onBlur={handleBlur}
onStop={onStop}
/>
</div>
</div>
);
@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { TaskTrackingObservation } from "#/types/core/observations";
import { TaskListSection } from "./task-tracking/task-list-section";
import { ResultSection } from "./task-tracking/result-section";
interface TaskTrackingObservationContentProps {
event: TaskTrackingObservation;
@@ -9,101 +9,17 @@ interface TaskTrackingObservationContentProps {
export function TaskTrackingObservationContent({
event,
}: TaskTrackingObservationContentProps) {
const { t } = useTranslation();
const { command, task_list: taskList } = event.extras;
const shouldShowTaskList = command === "plan" && taskList.length > 0;
const getStatusIcon = (status: string) => {
switch (status) {
case "todo":
return "⏳";
case "in_progress":
return "🔄";
case "done":
return "✅";
default:
return "❓";
}
};
const getStatusClassName = (status: string) => {
if (status === "done") {
return "bg-green-800 text-green-200";
}
if (status === "in_progress") {
return "bg-yellow-800 text-yellow-200";
}
return "bg-gray-700 text-gray-300";
};
return (
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-300">
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
{taskList.length === 1 ? "item" : "items"})
</h3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<div className="space-y-3">
{taskList.map((task, index) => (
<div key={task.id} className="border-l-2 border-gray-600 pl-3">
<div className="flex items-start gap-2">
<span className="text-lg">
{getStatusIcon(task.status)}
</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-gray-400">
{index + 1}.
</span>
<span
className={`text-xs px-2 py-1 rounded uppercase font-semibold ${getStatusClassName(
task.status,
)}`}
>
{task.status.replace("_", " ")}
</span>
</div>
<h4 className="font-medium text-white mb-1">
{task.title}
</h4>
<p className="text-xs text-gray-400 mb-1">
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
</p>
{task.notes && (
<p className="text-sm text-gray-300 italic">
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}:{" "}
{task.notes}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
{/* Result message - only show if there's meaningful content */}
{event.content && event.content.trim() && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-300">
{t("TASK_TRACKING_OBSERVATION$RESULT")}
</h3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
<pre className="whitespace-pre-wrap text-sm">
{event.content.trim()}
</pre>
</div>
</div>
<ResultSection content={event.content} />
)}
</div>
);
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
interface ResultSectionProps {
content: string;
}
export function ResultSection({ content }: ResultSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { getStatusClassName } from "#/utils/utils";
interface StatusBadgeProps {
status: string;
}
export function StatusBadge({ status }: StatusBadgeProps) {
return (
<span
className={`text-xs px-2 py-1 rounded uppercase font-semibold ${getStatusClassName(
status,
)}`}
>
{status.replace("_", " ")}
</span>
);
}
@@ -0,0 +1,9 @@
import { getStatusIcon } from "#/utils/utils";
interface StatusIconProps {
status: string;
}
export function StatusIcon({ status }: StatusIconProps) {
return <span className="text-lg">{getStatusIcon(status)}</span>;
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { StatusIcon } from "./status-icon";
import { StatusBadge } from "./status-badge";
interface TaskItemProps {
task: {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
notes?: string;
};
index: number;
}
export function TaskItem({ task, index }: TaskItemProps) {
const { t } = useTranslation();
return (
<div className="border-l-2 border-gray-600 pl-3">
<div className="flex items-start gap-2">
<StatusIcon status={task.status} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Typography.Text className="text-sm text-gray-400">
{index + 1}.
</Typography.Text>
<StatusBadge status={task.status} />
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
<Typography.Text className="text-xs text-gray-400 mb-1">
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
</Typography.Text>
{task.notes && (
<Typography.Text className="text-sm text-gray-300 italic">
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
</Typography.Text>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
taskList: Array<{
id: string;
title: string;
status: "todo" | "in_progress" | "done";
notes?: string;
}>;
}
export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
{taskList.length === 1 ? "item" : "items"})
</Typography.H3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<div className="space-y-3">
{taskList.map((task, index) => (
<TaskItem key={task.id} task={task} index={index} />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* Utility functions for chat input component
*/
/* eslint-disable no-param-reassign */
/**
* Check if contentEditable element is truly empty
*/
export const isContentEmpty = (element: HTMLDivElement | null): boolean => {
if (!element) {
return true;
}
const text = element.innerText || element.textContent || "";
return text.trim() === "";
};
/**
* Clear empty content from contentEditable element for placeholder display
*/
export const clearEmptyContent = (element: HTMLDivElement | null): void => {
if (element && isContentEmpty(element)) {
element.innerHTML = "";
element.textContent = "";
}
};
/**
* Get text content from contentEditable element
*/
export const getTextContent = (element: HTMLDivElement | null): string =>
element?.innerText || "";
/**
* Clear text content from contentEditable element
*/
export const clearTextContent = (element: HTMLDivElement | null): void => {
if (element) {
element.textContent = "";
}
};
/**
* Clear file input value
*/
export const clearFileInput = (element: HTMLInputElement | null): void => {
if (element) {
element.value = "";
}
};
/**
* Ensure cursor stays visible when content is scrollable
*/
export const ensureCursorVisible = (element: HTMLDivElement | null): void => {
if (!element) {
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (!range.getBoundingClientRect || !element.getBoundingClientRect) {
return;
}
const rect = range.getBoundingClientRect();
const inputRect = element.getBoundingClientRect();
// If cursor is below the visible area, scroll to show it
if (rect.bottom > inputRect.bottom) {
element.scrollTop = element.scrollHeight - element.clientHeight;
}
};
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { useEffect } from "react";
import { RootState } from "#/store";
import { useStatusStore } from "#/state/status-store";
import { useWsClient } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { getStatusCode } from "#/utils/status";
@@ -30,7 +31,7 @@ export function AgentStatus({
const { t } = useTranslation();
const dispatch = useDispatch();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const { curStatusMessage } = useStatusStore();
const { webSocketStatus } = useWsClient();
const { data: conversation } = useActiveConversation();
@@ -0,0 +1,65 @@
import React from "react";
import { cn } from "#/utils/utils";
import { ConversationStatus } from "#/types/conversation-status";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import EllipsisIcon from "#/icons/ellipsis.svg?react";
interface ConversationCardActionsProps {
contextMenuOpen: boolean;
onContextMenuToggle: (isOpen: boolean) => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
conversationStatus?: ConversationStatus;
conversationId?: string;
showOptions?: boolean;
}
export function ConversationCardActions({
contextMenuOpen,
onContextMenuToggle,
onDelete,
onStop,
onEdit,
onDownloadViaVSCode,
conversationStatus,
conversationId,
showOptions,
}: ConversationCardActionsProps) {
return (
<div className="group">
<button
data-testid="ellipsis-button"
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenuToggle(!contextMenuOpen);
}}
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
>
<EllipsisIcon />
</button>
<div
className={cn(
// Show on hover (desktop) or when explicitly opened (click/touch)
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
// Override hover styles when explicitly opened via click
contextMenuOpen && "opacity-100 visible",
)}
>
<ConversationCardContextMenu
onClose={() => onContextMenuToggle(false)}
onDelete={onDelete}
onStop={conversationStatus !== "STOPPED" ? onStop : undefined}
onEdit={onEdit}
onDownloadViaVSCode={
conversationId && showOptions ? onDownloadViaVSCode : undefined
}
position="bottom"
/>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { useTranslation } from "react-i18next";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { RepositorySelection } from "#/api/open-hands.types";
import { ConversationRepoLink } from "./conversation-repo-link";
import { NoRepository } from "./no-repository";
interface ConversationCardFooterProps {
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
}
export function ConversationCardFooter({
selectedRepository,
lastUpdatedAt,
createdAt,
}: ConversationCardFooterProps) {
const { t } = useTranslation();
return (
<div className={cn("flex flex-row justify-between items-center mt-1")}>
{selectedRepository?.selected_repository ? (
<ConversationRepoLink selectedRepository={selectedRepository} />
) : (
<NoRepository />
)}
{(createdAt ?? lastUpdatedAt) && (
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
<time>
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
</time>
</p>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import { ConversationStatus } from "#/types/conversation-status";
import { ConversationCardTitle } from "./conversation-card-title";
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
import { ConversationStatusBadges } from "./conversation-status-badges";
interface ConversationCardHeaderProps {
title: string;
titleMode: "view" | "edit";
onTitleSave: (title: string) => void;
conversationStatus?: ConversationStatus;
}
export function ConversationCardHeader({
title,
titleMode,
onTitleSave,
conversationStatus,
}: ConversationCardHeaderProps) {
return (
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator */}
{conversationStatus && (
<div className="flex items-center">
<ConversationStatusIndicator
conversationStatus={conversationStatus}
/>
</div>
)}
<ConversationCardTitle
title={title}
titleMode={titleMode}
onSave={onTitleSave}
/>
{/* Status Badges */}
{conversationStatus && (
<ConversationStatusBadges conversationStatus={conversationStatus} />
)}
</div>
);
}
@@ -1,28 +1,13 @@
import React from "react";
import { useSelector } from "react-redux";
import posthog from "posthog-js";
import { useTranslation } from "react-i18next";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { SystemMessageModal } from "../system-message-modal";
import { MicroagentsModal } from "../microagents-modal";
import { BudgetDisplay } from "../budget-display";
import { cn } from "#/utils/utils";
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useWsClient } from "#/context/ws-client-provider";
import { isSystemMessage } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import { RepositorySelection } from "#/api/open-hands.types";
import EllipsisIcon from "#/icons/ellipsis.svg?react";
import { ConversationCardTitle } from "./conversation-card-title";
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
import { ConversationStatusBadges } from "./conversation-status-badges";
import { NoRepository } from "./no-repository";
import { ConversationCardHeader } from "./conversation-card-header";
import { ConversationCardActions } from "./conversation-card-actions";
import { ConversationCardFooter } from "./conversation-card-footer";
interface ConversationCardProps {
onClick?: () => void;
@@ -57,18 +42,7 @@ export function ConversationCard({
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
const [microagentsModalVisible, setMicroagentsModalVisible] =
React.useState(false);
const systemMessage = parsedEvents.find(isSystemMessage);
// Subscribe to metrics data from Redux store
const metrics = useSelector((state: RootState) => state.metrics);
const onTitleSave = (newTitle: string) => {
if (newTitle !== "" && newTitle !== title) {
@@ -124,250 +98,47 @@ export function ConversationCard({
onContextMenuToggle?.(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setMetricsModalVisible(true);
};
const handleShowAgentTools = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setSystemModalVisible(true);
};
const handleShowMicroagents = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.stopPropagation();
setMicroagentsModalVisible(true);
};
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
return (
<>
<div
data-testid="conversation-card"
data-context-menu-open={contextMenuOpen.toString()}
onClick={onClick}
className={cn(
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
"data-[context-menu-open=false]:hover:bg-[#454545]",
conversationStatus === "ARCHIVED" && "opacity-60",
<div
data-testid="conversation-card"
data-context-menu-open={contextMenuOpen.toString()}
onClick={onClick}
className={cn(
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
"data-[context-menu-open=false]:hover:bg-[#454545]",
conversationStatus === "ARCHIVED" && "opacity-60",
)}
>
<div className="flex items-center justify-between w-full">
<ConversationCardHeader
title={title}
titleMode={titleMode}
onTitleSave={onTitleSave}
conversationStatus={conversationStatus}
/>
{hasContextMenu && (
<ConversationCardActions
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={onContextMenuToggle || (() => {})}
onDelete={onDelete && handleDelete}
onStop={onStop && handleStop}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={handleDownloadViaVSCode}
conversationStatus={conversationStatus}
conversationId={conversationId}
showOptions={showOptions}
/>
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{/* Status Indicator */}
{conversationStatus && (
<div className="flex items-center">
<ConversationStatusIndicator
conversationStatus={conversationStatus}
/>
</div>
)}
<ConversationCardTitle
title={title}
titleMode={titleMode}
onSave={onTitleSave}
/>
{/* Status Badges */}
{conversationStatus && (
<ConversationStatusBadges
conversationStatus={conversationStatus}
/>
)}
</div>
{hasContextMenu && (
<div className="group">
<button
data-testid="ellipsis-button"
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenuToggle?.(!contextMenuOpen);
}}
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-end"
>
<EllipsisIcon />
</button>
<div
className={cn(
// Show on hover (desktop) or when explicitly opened (click/touch)
"relative opacity-0 invisible group-hover:opacity-100 group-hover:visible",
// Override hover styles when explicitly opened via click
contextMenuOpen && "opacity-100 visible",
)}
>
<ConversationCardContextMenu
onClose={() => onContextMenuToggle?.(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
? onStop && handleStop
: undefined
}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
? handleDownloadViaVSCode
: undefined
}
onDisplayCost={showOptions ? handleDisplayCost : undefined}
onShowAgentTools={
showOptions && systemMessage
? handleShowAgentTools
: undefined
}
onShowMicroagents={
showOptions && conversationId
? handleShowMicroagents
: undefined
}
position="bottom"
/>
</div>
</div>
)}
</div>
<div className={cn("flex flex-row justify-between items-center mt-1")}>
{selectedRepository?.selected_repository ? (
<ConversationRepoLink selectedRepository={selectedRepository} />
) : (
<NoRepository />
)}
{(createdAt ?? lastUpdatedAt) && (
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
<time>
{`${formatTimeDelta(new Date(lastUpdatedAt ?? createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
</time>
</p>
)}
</div>
</div>
<BaseModal
isOpen={metricsModalVisible}
onOpenChange={setMetricsModalVisible}
title={t(I18nKey.CONVERSATION$METRICS_INFO)}
testID="metrics-modal"
>
<div className="space-y-4">
{(metrics?.cost !== null || metrics?.usage !== null) && (
<div className="rounded-md p-3">
<div className="grid gap-3">
{metrics?.cost !== null && (
<div className="flex justify-between items-center pb-2">
<span className="text-lg font-semibold">
{t(I18nKey.CONVERSATION$TOTAL_COST)}
</span>
<span className="font-semibold">
${metrics.cost.toFixed(4)}
</span>
</div>
)}
<BudgetDisplay
cost={metrics?.cost ?? null}
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
/>
{metrics?.usage !== null && (
<>
<div className="flex justify-between items-center pb-2">
<span>{t(I18nKey.CONVERSATION$INPUT)}</span>
<span className="font-semibold">
{metrics.usage.prompt_tokens.toLocaleString()}
</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_HIT)}
</span>
<span className="text-right">
{metrics.usage.cache_read_tokens.toLocaleString()}
</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
</span>
<span className="text-right">
{metrics.usage.cache_write_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span>{t(I18nKey.CONVERSATION$OUTPUT)}</span>
<span className="font-semibold">
{metrics.usage.completion_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$TOTAL)}
</span>
<span className="font-bold">
{(
metrics.usage.prompt_tokens +
metrics.usage.completion_tokens
).toLocaleString()}
</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-semibold">
{t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}
</span>
</div>
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${Math.min(100, (metrics.usage.per_turn_token / metrics.usage.context_window) * 100)}%`,
}}
/>
</div>
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
{metrics.usage.per_turn_token.toLocaleString()} /{" "}
{metrics.usage.context_window.toLocaleString()} (
{(
(metrics.usage.per_turn_token /
metrics.usage.context_window) *
100
).toFixed(2)}
% {t(I18nKey.CONVERSATION$USED)})
</span>
</div>
</div>
</>
)}
</div>
</div>
)}
{!metrics?.cost && !metrics?.usage && (
<div className="rounded-md p-4 text-center">
<p className="text-neutral-400">
{t(I18nKey.CONVERSATION$NO_METRICS)}
</p>
</div>
)}
</div>
</BaseModal>
<SystemMessageModal
isOpen={systemModalVisible}
onClose={() => setSystemModalVisible(false)}
systemMessage={systemMessage ? systemMessage.args : null}
<ConversationCardFooter
selectedRepository={selectedRepository}
lastUpdatedAt={lastUpdatedAt}
createdAt={createdAt}
/>
{microagentsModalVisible && (
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
)}
</>
</div>
);
}
@@ -1,86 +0,0 @@
import { useSelector } from "react-redux";
import { useWindowSize } from "@uidotdev/usehooks";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { ChatInterface } from "../chat/chat-interface";
import { ConversationTabContent } from "./conversation-tabs/conversation-tab-content/conversation-tab-content";
import { cn } from "#/utils/utils";
import { RootState } from "#/store";
interface ChatInterfaceWrapperProps {
isRightPanelShown: boolean;
}
export function ChatInterfaceWrapper({
isRightPanelShown,
}: ChatInterfaceWrapperProps) {
if (!isRightPanelShown) {
return (
<div className="flex justify-center w-full h-full">
<div className="w-full max-w-[768px]">
<ChatInterface />
</div>
</div>
);
}
return <ChatInterface />;
}
export function ConversationMain() {
const { width } = useWindowSize();
const isRightPanelShown = useSelector(
(state: RootState) => state.conversation.isRightPanelShown,
);
if (width && width <= 1024) {
return (
<div className="flex flex-col gap-3 overflow-auto w-full">
<div
className={cn(
"overflow-hidden w-full bg-base min-h-[600px]",
!isRightPanelShown && "h-full",
)}
>
<ChatInterface />
</div>
{isRightPanelShown && (
<div className="h-full w-full min-h-[494px] flex flex-col gap-3">
<ConversationTabContent />
</div>
)}
</div>
);
}
if (isRightPanelShown) {
return (
<PanelGroup
direction="horizontal"
className="grow h-full min-h-0 min-w-0"
autoSaveId="react-resizable-panels:layout"
>
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</Panel>
<PanelResizeHandle className="cursor-ew-resize" />
<Panel
minSize={20}
maxSize={70}
className="flex flex-col overflow-hidden"
>
<div className="flex flex-col flex-1 gap-3">
<ConversationTabContent />
</div>
</Panel>
</PanelGroup>
);
}
return (
<div className="flex flex-col gap-3 overflow-auto w-full h-full">
<div className="overflow-hidden w-full h-full bg-base">
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { ChatInterface } from "../../chat/chat-interface";
interface ChatInterfaceWrapperProps {
isRightPanelShown: boolean;
}
export function ChatInterfaceWrapper({
isRightPanelShown,
}: ChatInterfaceWrapperProps) {
if (!isRightPanelShown) {
return (
<div className="flex justify-center w-full h-full">
<div className="w-full max-w-[768px]">
<ChatInterface />
</div>
</div>
);
}
return <ChatInterface />;
}
@@ -0,0 +1,18 @@
import { useSelector } from "react-redux";
import { useWindowSize } from "@uidotdev/usehooks";
import { RootState } from "#/store";
import { MobileLayout } from "./mobile-layout";
import { DesktopLayout } from "./desktop-layout";
export function ConversationMain() {
const { width } = useWindowSize();
const isRightPanelShown = useSelector(
(state: RootState) => state.conversation.isRightPanelShown,
);
if (width && width <= 1024) {
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
}
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
}
@@ -0,0 +1,35 @@
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
interface DesktopLayoutProps {
isRightPanelShown: boolean;
}
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
return (
<PanelGroup
direction="horizontal"
className="grow h-full min-h-0 min-w-0"
autoSaveId="react-resizable-panels:layout"
>
<Panel minSize={30} maxSize={80} className="overflow-hidden bg-base">
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
</Panel>
{isRightPanelShown && (
<>
<PanelResizeHandle className="cursor-ew-resize" />
<Panel
minSize={20}
maxSize={70}
className="flex flex-col overflow-hidden"
>
<div className="flex flex-col flex-1 gap-3">
<ConversationTabContent />
</div>
</Panel>
</>
)}
</PanelGroup>
);
}
@@ -0,0 +1,27 @@
import { ChatInterface } from "../../chat/chat-interface";
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
import { cn } from "#/utils/utils";
interface MobileLayoutProps {
isRightPanelShown: boolean;
}
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
return (
<div className="flex flex-col gap-3 overflow-auto w-full">
<div
className={cn(
"overflow-hidden w-full bg-base min-h-[600px]",
!isRightPanelShown && "h-full",
)}
>
<ChatInterface />
</div>
{isRightPanelShown && (
<div className="h-full w-full min-h-[494px] flex flex-col gap-3">
<ConversationTabContent />
</div>
)}
</div>
);
}
@@ -16,6 +16,7 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
const TasksTab = lazy(() => import("#/routes/tasks-tab"));
export function ConversationTabContent() {
const selectedTab = useSelector(
@@ -34,6 +35,7 @@ export function ConversationTabContent() {
const isServedActive = selectedTab === "served";
const isVSCodeActive = selectedTab === "vscode";
const isTerminalActive = selectedTab === "terminal";
const isTasksActive = selectedTab === "tasks";
// Define tab configurations
const tabs = [
@@ -55,6 +57,7 @@ export function ConversationTabContent() {
component: Terminal,
isActive: isTerminalActive,
},
{ key: "tasks", component: TasksTab, isActive: isTasksActive },
];
const conversationTabTitle = useMemo(() => {
@@ -76,6 +79,9 @@ export function ConversationTabContent() {
if (isTerminalActive) {
return t(I18nKey.COMMON$TERMINAL);
}
if (isTasksActive) {
return t(I18nKey.COMMON$TASKS);
}
return "";
}, [
isEditorActive,
@@ -84,6 +90,7 @@ export function ConversationTabContent() {
isServedActive,
isVSCodeActive,
isTerminalActive,
isTasksActive,
]);
if (shouldShownAgentLoading) {
@@ -5,12 +5,14 @@ type ConversationTabNavProps = {
icon: ComponentType<{ className: string }>;
onClick(): void;
isActive?: boolean;
"data-testid"?: string;
};
export function ConversationTabNav({
icon: Icon,
onClick,
isActive,
"data-testid": dataTestId,
}: ConversationTabNavProps) {
return (
<button
@@ -18,6 +20,7 @@ export function ConversationTabNav({
onClick={() => {
onClick();
}}
data-testid={dataTestId}
className={cn(
"p-1 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
@@ -8,6 +8,7 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import ListIcon from "#/icons/list.svg?react";
import { cn } from "#/utils/utils";
import { ConversationTabNav } from "./conversation-tab-nav";
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
@@ -130,8 +131,19 @@ export function ConversationTabs() {
tooltipContent: t(I18nKey.COMMON$BROWSER),
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
},
{
isActive: isTabActive("tasks"),
icon: ListIcon,
onClick: () => onTabSelected("tasks"),
tooltipContent: t(I18nKey.COMMON$TASKS),
tooltipAriaLabel: t(I18nKey.COMMON$TASKS),
},
];
// Debug logging to help troubleshoot tab visibility
console.log("ConversationTabs: Rendering", tabs.length, "tabs");
console.log("ConversationTabs: Tasks tab config:", tabs[tabs.length - 1]);
return (
<div
className={cn(
@@ -153,6 +165,7 @@ export function ConversationTabs() {
icon={icon}
onClick={onClick}
isActive={isActive}
data-testid={index === tabs.length - 1 ? "tasks-tab" : undefined}
/>
</ChatActionTooltip>
),
@@ -1,12 +1,11 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { CostSection } from "./cost-section";
import { UsageSection } from "./usage-section";
import { ContextWindowSection } from "./context-window-section";
import { EmptyState } from "./empty-state";
import useMetricsStore from "#/stores/metrics-store";
interface MetricsModalProps {
isOpen: boolean;
@@ -15,7 +14,7 @@ interface MetricsModalProps {
export function MetricsModal({ isOpen, onOpenChange }: MetricsModalProps) {
const { t } = useTranslation();
const metrics = useSelector((state: RootState) => state.metrics);
const metrics = useMetricsStore();
return (
<BaseModal
@@ -7,14 +7,11 @@ import { cn } from "#/utils/utils";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
function Terminal() {
const { commands } = useSelector((state: RootState) => state.cmd);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const ref = useTerminal({
commands,
});
const ref = useTerminal();
return (
<div className="h-full flex flex-col rounded-xl">
@@ -0,0 +1,99 @@
import { useCallback } from "react";
import { isMobileDevice } from "#/utils/utils";
import {
ensureCursorVisible,
clearEmptyContent,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for handling chat input events
*/
export const useChatInputEvents = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
smartResize: () => void,
increaseHeightForEmptyContent: () => void,
checkIsContentEmpty: () => boolean,
clearEmptyContentHandler: () => void,
onFocus?: () => void,
onBlur?: () => void,
) => {
// Handle input events
const handleInput = useCallback(() => {
smartResize();
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent(chatInputRef.current);
}
// Ensure cursor stays visible when content is scrollable
ensureCursorVisible(chatInputRef.current);
}, [smartResize, chatInputRef]);
// Handle paste events to clean up formatting
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault();
// Get plain text from clipboard
const text = e.clipboardData.getData("text/plain");
// Insert plain text
document.execCommand("insertText", false, text);
// Trigger resize
setTimeout(smartResize, 0);
},
[smartResize],
);
// Handle key events
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, disabled: boolean, handleSubmit: () => void) => {
if (e.key !== "Enter") {
return;
}
if (checkIsContentEmpty()) {
e.preventDefault();
increaseHeightForEmptyContent();
return;
}
// Original submit logic - only for desktop without shift key
if (!isMobileDevice() && !e.shiftKey && !disabled) {
e.preventDefault();
handleSubmit();
}
},
[checkIsContentEmpty, increaseHeightForEmptyContent],
);
// Handle blur events to ensure placeholder shows when empty
const handleBlur = useCallback(() => {
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent(chatInputRef.current);
}
// Call the original onBlur callback if provided
if (onBlur) {
onBlur();
}
}, [chatInputRef, onBlur]);
// Handle focus events
const handleFocus = useCallback(() => {
if (onFocus) {
onFocus();
}
}, [onFocus]);
return {
handleInput,
handlePaste,
handleKeyDown,
handleBlur,
handleFocus,
};
};
@@ -0,0 +1,59 @@
import { useRef, useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
setMessageToSend,
setIsRightPanelShown,
} from "#/state/conversation-slice";
import { RootState } from "#/store";
import {
isContentEmpty,
clearEmptyContent,
getTextContent,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for managing chat input content logic
*/
export const useChatInputLogic = () => {
const chatInputRef = useRef<HTMLDivElement | null>(null);
const { messageToSend, hasRightPanelToggled } = useSelector(
(state: RootState) => state.conversation,
);
const dispatch = useDispatch();
// Save current input value when drawer state changes
useEffect(() => {
if (chatInputRef.current) {
const currentText = getTextContent(chatInputRef.current);
dispatch(setMessageToSend(currentText));
dispatch(setIsRightPanelShown(hasRightPanelToggled));
}
}, [hasRightPanelToggled, dispatch]);
// Helper function to check if contentEditable is truly empty
const checkIsContentEmpty = useCallback(
(): boolean => isContentEmpty(chatInputRef.current),
[],
);
// Helper function to properly clear contentEditable for placeholder display
const clearEmptyContentHandler = useCallback((): void => {
clearEmptyContent(chatInputRef.current);
}, []);
// Get current message text
const getCurrentMessage = useCallback(
(): string => getTextContent(chatInputRef.current),
[],
);
return {
chatInputRef,
messageToSend,
checkIsContentEmpty,
clearEmptyContentHandler,
getCurrentMessage,
};
};
@@ -0,0 +1,61 @@
import { useCallback } from "react";
import {
clearTextContent,
clearFileInput,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for handling chat message submission
*/
export const useChatSubmission = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
fileInputRef: React.RefObject<HTMLInputElement | null>,
smartResize: () => void,
onSubmit: (message: string) => void,
) => {
// Send button click handler
const handleSubmit = useCallback(() => {
const message = chatInputRef.current?.innerText || "";
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return;
}
onSubmit(message);
// Clear the input
clearTextContent(chatInputRef.current);
clearFileInput(fileInputRef.current);
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Resume agent button click handler
const handleResumeAgent = useCallback(() => {
const message = chatInputRef.current?.innerText || "continue";
onSubmit(message.trim());
// Clear the input
clearTextContent(chatInputRef.current);
clearFileInput(fileInputRef.current);
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Handle stop button click
const handleStop = useCallback((onStop?: () => void) => {
if (onStop) {
onStop();
}
}, []);
return {
handleSubmit,
handleResumeAgent,
handleStop,
};
};
@@ -0,0 +1,103 @@
import React, { useRef, useCallback, useState } from "react";
interface UseFileHandlingReturn {
fileInputRef: React.RefObject<HTMLInputElement | null>;
chatContainerRef: React.RefObject<HTMLDivElement | null>;
isDragOver: boolean;
handleFileIconClick: (isDisabled: boolean) => void;
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
handleDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
handleDrop: (e: React.DragEvent, isDisabled: boolean) => void;
}
/**
* Hook for handling file operations (upload, drag & drop)
*/
export const useFileHandling = (
onFilesPaste?: (files: File[]) => void,
): UseFileHandlingReturn => {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
// Function to add files and notify parent
const addFiles = useCallback(
(files: File[]) => {
if (onFilesPaste && files.length > 0) {
onFilesPaste(files);
}
},
[onFilesPaste],
);
// File icon click handler
const handleFileIconClick = useCallback((isDisabled: boolean) => {
if (!isDisabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
addFiles(files);
},
[addFiles],
);
// Drag and drop event handlers
const handleDragOver = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (isDisabled) {
return;
}
e.preventDefault();
setIsDragOver(true);
},
[],
);
const handleDragLeave = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (
isDisabled ||
chatContainerRef.current?.contains(e.relatedTarget as Node)
) {
return;
}
e.preventDefault();
setIsDragOver(false);
},
[],
);
const handleDrop = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (isDisabled) {
return;
}
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
},
[addFiles],
);
return {
fileInputRef,
chatContainerRef,
isDragOver,
handleFileIconClick,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
};
};
@@ -0,0 +1,81 @@
import { useRef, useState, useCallback } from "react";
import { useDispatch } from "react-redux";
import { useAutoResize } from "#/hooks/use-auto-resize";
import {
IMessageToSend,
setShouldHideSuggestions,
} from "#/state/conversation-slice";
import { CHAT_INPUT } from "#/utils/constants";
/**
* Hook for managing grip resize functionality
*/
export const useGripResize = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
messageToSend: IMessageToSend | null,
) => {
const gripRef = useRef<HTMLDivElement | null>(null);
const [isGripVisible, setIsGripVisible] = useState(false);
const dispatch = useDispatch();
// Drag state management callbacks
const handleDragStart = useCallback(() => {
// Keep grip visible during drag by adding a CSS class
if (gripRef.current) {
gripRef.current.classList.add("opacity-100");
gripRef.current.classList.remove("opacity-0");
}
}, []);
const handleDragEnd = useCallback(() => {
// Restore hover-based visibility
if (gripRef.current) {
gripRef.current.classList.remove("opacity-100");
gripRef.current.classList.add("opacity-0");
}
}, []);
// Handle click on top edge area to toggle grip visibility
const handleTopEdgeClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsGripVisible((prev) => !prev);
}, []);
// Callback to handle height changes and manage suggestions visibility
const handleHeightChange = useCallback(
(height: number) => {
// Hide suggestions when input height exceeds the threshold
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
},
[dispatch],
);
// Use the auto-resize hook with height change callback
const {
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
} = useAutoResize(chatInputRef as React.RefObject<HTMLElement | null>, {
minHeight: 20,
maxHeight: 400,
onHeightChange: handleHeightChange,
onGripDragStart: handleDragStart,
onGripDragEnd: handleDragEnd,
value: messageToSend ?? undefined,
enableManualResize: true,
});
return {
gripRef,
isGripVisible,
handleTopEdgeClick,
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
};
};
+100
View File
@@ -0,0 +1,100 @@
import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
interface Task {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
notes?: string;
}
const parseTasksFromMarkdown = (content: string): Task[] => {
const tasks: Task[] = [];
const lines = content.split("\n");
let currentTask: Partial<Task> | null = null;
for (const line of lines) {
// Match task lines like: "1. ✅ Add 'tasks' to ConversationTab type in conversation-slice.tsx"
const taskMatch = line.match(/^(\d+)\.\s*([🔄])\s*(.+)$/u);
if (taskMatch) {
// Save previous task if exists
if (
currentTask &&
currentTask.id &&
currentTask.title &&
currentTask.status
) {
tasks.push(currentTask as Task);
}
const [, id, statusIcon, title] = taskMatch;
let status: Task["status"] = "todo";
// Determine status from emoji
if (statusIcon === "✅") {
status = "done";
} else if (statusIcon === "🔄") {
status = "in_progress";
} else if (statusIcon === "⏳") {
status = "todo";
}
currentTask = {
id,
title: title.trim(),
status,
notes: "",
};
} else if (currentTask && line.trim() && !line.startsWith("#")) {
// This is likely a notes line for the current task
if (currentTask.notes) {
currentTask.notes += ` ${line.trim()}`;
} else {
currentTask.notes = line.trim();
}
}
}
// Don't forget the last task
if (
currentTask &&
currentTask.id &&
currentTask.title &&
currentTask.status
) {
tasks.push(currentTask as Task);
}
return tasks;
};
export const useGetTasks = () => {
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
return useQuery({
queryKey: ["tasks", conversationId],
queryFn: async () => {
try {
const content = await ConversationService.getFile(
conversationId,
"TASKS.md",
);
return parseTasksFromMarkdown(content);
} catch (error) {
// If TASKS.md doesn't exist, return empty array
return [];
}
},
retry: false,
staleTime: 1000 * 30, // 30 seconds
gcTime: 1000 * 60 * 5, // 5 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
disableToast: true,
},
});
};
@@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next";
import React from "react";
import posthog from "posthog-js";
import { useParams, useNavigate } from "react-router";
import { useSelector } from "react-redux";
import { useWsClient } from "#/context/ws-client-provider";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { RootState } from "#/store";
import useMetricsStore from "#/stores/metrics-store";
import { isSystemMessage } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -36,7 +35,7 @@ export function useConversationNameContextMenu({
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: getTrajectory } = useGetTrajectory();
const metrics = useSelector((state: RootState) => state.metrics);
const metrics = useMetricsStore();
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
+4 -13
View File
@@ -2,26 +2,18 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import React from "react";
import { useSelector } from "react-redux";
import { Command } from "#/state/command-slice";
import { RootState } from "#/store";
import { Command, useCommandStore } from "#/state/command-store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
import { getTerminalCommand } from "#/services/terminal-service";
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
import { RootState } from "#/store";
/*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
The reason for this is that the hook exposes a ref that requires a DOM element to be rendered.
*/
interface UseTerminalConfig {
commands: Command[];
}
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
};
const renderCommand = (
command: Command,
terminal: Terminal,
@@ -44,11 +36,10 @@ const renderCommand = (
// This ensures terminal history is preserved when navigating away and back
const persistentLastCommandIndex = { current: 0 };
export const useTerminal = ({
commands,
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
export const useTerminal = () => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const commands = useCommandStore((state) => state.commands);
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
+3
View File
@@ -890,6 +890,7 @@ export enum I18nKey {
COMMON$START_CONVERSATION = "COMMON$START_CONVERSATION",
COMMON$STOP_SERVER = "COMMON$STOP_SERVER",
COMMON$TERMINAL = "COMMON$TERMINAL",
COMMON$TASKS = "COMMON$TASKS",
COMMON$UNKNOWN = "COMMON$UNKNOWN",
COMMON$USER_SETTINGS = "COMMON$USER_SETTINGS",
COMMON$VIEW = "COMMON$VIEW",
@@ -910,4 +911,6 @@ export enum I18nKey {
COMMON$STOP_RUNTIME = "COMMON$STOP_RUNTIME",
COMMON$START_RUNTIME = "COMMON$START_RUNTIME",
COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE",
COMMON$ERROR_LOADING_TASKS = "COMMON$ERROR_LOADING_TASKS",
COMMON$NO_TASKS_AVAILABLE = "COMMON$NO_TASKS_AVAILABLE",
}
+48
View File
@@ -14239,6 +14239,22 @@
"de": "Terminal",
"uk": "Термінал"
},
"COMMON$TASKS": {
"en": "Task List",
"ja": "タスクリスト",
"zh-CN": "任务列表",
"zh-TW": "任務列表",
"ko-KR": "작업 목록",
"no": "Oppgaveliste",
"it": "Elenco attività",
"pt": "Lista de tarefas",
"es": "Lista de tareas",
"ar": "قائمة المهام",
"fr": "Liste des tâches",
"tr": "Görev Listesi",
"de": "Aufgabenliste",
"uk": "Список завдань"
},
"COMMON$UNKNOWN": {
"en": "Unknown",
"ja": "不明",
@@ -14558,5 +14574,37 @@
"tr": "Jupyter defteriniz boş. Gösterilecek hücre yok.",
"de": "Ihr Jupyter-Notebook ist leer. Keine Zellen zum Anzeigen.",
"uk": "Ваш Jupyter-ноутбук порожній. Немає клітинок для відображення."
},
"COMMON$ERROR_LOADING_TASKS": {
"en": "Error loading tasks",
"ja": "タスクの読み込み中にエラーが発生しました",
"zh-CN": "加载任务时出错",
"zh-TW": "載入任務時發生錯誤",
"ko-KR": "작업을 불러오는 중 오류가 발생했습니다",
"no": "Feil ved lasting av oppgaver",
"it": "Errore nel caricamento delle attività",
"pt": "Erro ao carregar tarefas",
"es": "Error al cargar tareas",
"ar": "خطأ في تحميل المهام",
"fr": "Erreur lors du chargement des tâches",
"tr": "Görevler yüklenirken hata oluştu",
"de": "Fehler beim Laden der Aufgaben",
"uk": "Помилка завантаження завдань"
},
"COMMON$NO_TASKS_AVAILABLE": {
"en": "No tasks available",
"ja": "利用可能なタスクがありません",
"zh-CN": "没有可用的任务",
"zh-TW": "沒有可用的任務",
"ko-KR": "사용 가능한 작업이 없습니다",
"no": "Ingen oppgaver tilgjengelig",
"it": "Nessuna attività disponibile",
"pt": "Nenhuma tarefa disponível",
"es": "No hay tareas disponibles",
"ar": "لا توجد مهام متاحة",
"fr": "Aucune tâche disponible",
"tr": "Mevcut görev yok",
"de": "Keine Aufgaben verfügbar",
"uk": "Немає доступних завдань"
}
}
+6 -5
View File
@@ -4,7 +4,7 @@ import { useDispatch } from "react-redux";
import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { clearTerminal } from "#/state/command-slice";
import { useCommandStore } from "#/state/command-store";
import { useEffectOnce } from "#/hooks/use-effect-once";
import { clearJupyter } from "#/state/jupyter-slice";
import { resetConversationState } from "#/state/conversation-slice";
@@ -24,7 +24,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { useUserProviders } from "#/hooks/use-user-providers";
import { ConversationMain } from "#/components/features/conversation/conversation-main";
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
@@ -40,6 +40,7 @@ function AppContent() {
const { providers } = useUserProviders();
const dispatch = useDispatch();
const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal);
const queryClient = useQueryClient();
// Fetch batch feedback data when conversation is loaded
@@ -83,14 +84,14 @@ function AppContent() {
]);
React.useEffect(() => {
dispatch(clearTerminal());
clearTerminal();
dispatch(clearJupyter());
dispatch(resetConversationState());
dispatch(setCurrentAgentState(AgentState.LOADING));
}, [conversationId]);
}, [conversationId, clearTerminal]);
useEffectOnce(() => {
dispatch(clearTerminal());
clearTerminal();
dispatch(clearJupyter());
dispatch(resetConversationState());
dispatch(setCurrentAgentState(AgentState.LOADING));
+8 -6
View File
@@ -526,12 +526,14 @@ function LlmSettingsScreen() {
/>
)}
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
{config?.APP_MODE !== "saas" && (
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
)}
</div>
)}
+84
View File
@@ -0,0 +1,84 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { TaskListSection } from "#/components/features/chat/task-tracking/task-list-section";
import { useGetTasks } from "#/hooks/query/use-get-tasks";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
function StatusMessage({ children }: React.PropsWithChildren) {
return (
<div className="w-full h-full flex flex-col items-center text-center justify-center text-2xl text-tertiary-light">
{children}
</div>
);
}
function TasksTab() {
const { t } = useTranslation();
const {
data: tasks,
isSuccess,
isError,
error,
isLoading: loadingTasks,
} = useGetTasks();
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
null,
);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
React.useEffect(() => {
if (!runtimeIsActive) {
setStatusMessage([I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME]);
} else if (error) {
setStatusMessage([I18nKey.COMMON$ERROR_LOADING_TASKS]);
} else if (loadingTasks) {
setStatusMessage([I18nKey.HOME$LOADING]);
} else {
setStatusMessage(null);
}
}, [runtimeIsActive, loadingTasks, error, setStatusMessage]);
return (
<main className="h-full overflow-y-scroll p-4 md:pr-1.5 gap-3 flex flex-col items-center custom-scrollbar-always">
{!isSuccess || !tasks || tasks.length === 0 ? (
<div className="relative flex h-full w-full items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
{statusMessage && (
<StatusMessage>
{statusMessage.map((msg) => (
<span key={msg}>{t(msg)}</span>
))}
</StatusMessage>
)}
{!statusMessage && isSuccess && tasks && tasks.length === 0 && (
<StatusMessage>
<span>{t(I18nKey.COMMON$NO_TASKS_AVAILABLE)}</span>
</StatusMessage>
)}
</div>
<div className="absolute inset-x-0 bottom-0">
{!isError && tasks?.length === 0 && (
<div className="max-w-2xl mb-4 text-m bg-tertiary rounded-xl p-4 text-left mx-auto">
<RandomTip />
</div>
)}
</div>
</div>
) : (
<div className="w-full max-w-4xl">
<TaskListSection taskList={tasks} />
</div>
)}
</main>
);
}
export default TasksTab;
+25 -13
View File
@@ -3,7 +3,7 @@ import { handleStatusMessage } from "../actions";
import { StatusMessage } from "#/types/message";
import { queryClient } from "#/query-client-config";
import store from "#/store";
import { setCurStatusMessage } from "#/state/status-slice";
import { useStatusStore } from "#/state/status-store";
import { trackError } from "#/utils/error-handler";
// Mock dependencies
@@ -19,12 +19,12 @@ vi.mock("#/store", () => ({
},
}));
vi.mock("#/state/status-slice", () => ({
setCurStatusMessage: vi.fn(),
}));
vi.mock("#/state/chat-slice", () => ({
addErrorMessage: vi.fn(),
vi.mock("#/state/status-store", () => ({
useStatusStore: {
getState: vi.fn(() => ({
setCurStatusMessage: vi.fn(),
})),
},
}));
vi.mock("#/utils/error-handler", () => ({
@@ -61,7 +61,7 @@ describe("handleStatusMessage", () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should dispatch setCurStatusMessage for info messages without conversation_title", () => {
it("should call setCurStatusMessage for info messages without conversation_title", () => {
// Create a status message without a conversation title
const statusMessage: StatusMessage = {
status_update: true,
@@ -69,19 +69,28 @@ describe("handleStatusMessage", () => {
message: "Some info message",
};
const mockSetCurStatusMessage = vi.fn();
vi.mocked(useStatusStore.getState).mockReturnValue({
setCurStatusMessage: mockSetCurStatusMessage,
curStatusMessage: {
status_update: true,
type: "info",
id: "",
message: "",
},
});
// Call the function
handleStatusMessage(statusMessage);
// Verify that store.dispatch was called with setCurStatusMessage
expect(store.dispatch).toHaveBeenCalledWith(
setCurStatusMessage(statusMessage),
);
// Verify that setCurStatusMessage was called with the correct message
expect(mockSetCurStatusMessage).toHaveBeenCalledWith(statusMessage);
// Verify that queryClient.invalidateQueries was not called
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
it("should dispatch addErrorMessage for error messages", () => {
it("should call trackError for error messages", () => {
// Create an error status message
const statusMessage: StatusMessage = {
status_update: true,
@@ -100,6 +109,9 @@ describe("handleStatusMessage", () => {
metadata: { msgId: "ERROR_ID" },
});
// Verify that store.dispatch was not called
expect(store.dispatch).not.toHaveBeenCalled();
// Verify that queryClient.invalidateQueries was not called
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
+8 -10
View File
@@ -1,7 +1,7 @@
import { trackError } from "#/utils/error-handler";
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
import { setCurStatusMessage } from "#/state/status-slice";
import { setMetrics } from "#/state/metrics-slice";
import useMetricsStore from "#/stores/metrics-store";
import { useStatusStore } from "#/state/status-store";
import store from "#/store";
import ActionType from "#/types/action-type";
import {
@@ -10,7 +10,7 @@ import {
StatusMessage,
} from "#/types/message";
import { handleObservationMessage } from "./observations";
import { appendInput } from "#/state/command-slice";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterInput } from "#/state/jupyter-slice";
import { queryClient } from "#/query-client-config";
@@ -26,11 +26,11 @@ export function handleActionMessage(message: ActionMessage) {
max_budget_per_task: message.llm_metrics?.max_budget_per_task ?? null,
usage: message.llm_metrics?.accumulated_token_usage ?? null,
};
store.dispatch(setMetrics(metrics));
useMetricsStore.getState().setMetrics(metrics);
}
if (message.action === ActionType.RUN) {
store.dispatch(appendInput(message.args.command));
useCommandStore.getState().appendInput(message.args.command);
}
if (message.action === ActionType.RUN_IPYTHON) {
@@ -52,11 +52,9 @@ export function handleStatusMessage(message: StatusMessage) {
queryKey: ["user", "conversation", conversationId],
});
} else if (message.type === "info") {
store.dispatch(
setCurStatusMessage({
...message,
}),
);
useStatusStore.getState().setCurStatusMessage({
...message,
});
} else if (message.type === "error") {
trackError({
message: message.message,
+2 -2
View File
@@ -2,7 +2,7 @@ import { setCurrentAgentState } from "#/state/agent-slice";
import { setUrl, setScreenshotSrc } from "#/state/browser-slice";
import store from "#/store";
import { ObservationMessage } from "#/types/message";
import { appendOutput } from "#/state/command-slice";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterOutput } from "#/state/jupyter-slice";
import ObservationType from "#/types/observation-type";
@@ -19,7 +19,7 @@ export function handleObservationMessage(message: ObservationMessage) {
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...\r\n\n${tail}`;
}
store.dispatch(appendOutput(content));
useCommandStore.getState().appendOutput(content);
break;
}
case ObservationType.RUN_IPYTHON:
-58
View File
@@ -1,58 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export interface FileState {
path: string;
savedContent: string;
unsavedContent: string;
}
export const initialState = {
code: "",
path: "",
refreshID: 0,
fileStates: [] as FileState[],
};
export const codeSlice = createSlice({
name: "code",
initialState,
reducers: {
setCode: (state, action) => {
state.code = action.payload;
},
setActiveFilepath: (state, action) => {
state.path = action.payload;
},
setRefreshID: (state, action) => {
state.refreshID = action.payload;
},
setFileStates: (state, action) => {
state.fileStates = action.payload;
},
addOrUpdateFileState: (state, action) => {
const { path, unsavedContent, savedContent } = action.payload;
const newFileStates = state.fileStates.filter(
(fileState) => fileState.path !== path,
);
newFileStates.push({ path, savedContent, unsavedContent });
state.fileStates = newFileStates;
},
removeFileState: (state, action) => {
const path = action.payload;
state.fileStates = state.fileStates.filter(
(fileState) => fileState.path !== path,
);
},
},
});
export const {
setCode,
setActiveFilepath,
setRefreshID,
addOrUpdateFileState,
removeFileState,
setFileStates,
} = codeSlice.actions;
export default codeSlice.reducer;
-31
View File
@@ -1,31 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export type Command = {
content: string;
type: "input" | "output";
};
const initialCommands: Command[] = [];
export const commandSlice = createSlice({
name: "command",
initialState: {
commands: initialCommands,
},
reducers: {
appendInput: (state, action) => {
state.commands.push({ content: action.payload, type: "input" });
},
appendOutput: (state, action) => {
state.commands.push({ content: action.payload, type: "output" });
},
clearTerminal: (state) => {
state.commands = [];
},
},
});
export const { appendInput, appendOutput, clearTerminal } =
commandSlice.actions;
export default commandSlice.reducer;
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
export type Command = {
content: string;
type: "input" | "output";
};
interface CommandState {
commands: Command[];
appendInput: (content: string) => void;
appendOutput: (content: string) => void;
clearTerminal: () => void;
}
export const useCommandStore = create<CommandState>((set) => ({
commands: [],
appendInput: (content: string) =>
set((state) => ({
commands: [...state.commands, { content, type: "input" }],
})),
appendOutput: (content: string) =>
set((state) => ({
commands: [...state.commands, { content, type: "output" }],
})),
clearTerminal: () => set({ commands: [] }),
}));
+2 -1
View File
@@ -6,7 +6,8 @@ export type ConversationTab =
| "jupyter"
| "served"
| "vscode"
| "terminal";
| "terminal"
| "tasks";
export interface IMessageToSend {
text: string;
-62
View File
@@ -1,62 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
type SliceState = {
files: string[]; // base64 encoded images
initialPrompt: string | null;
selectedRepository: GitRepository | null;
selectedRepositoryProvider: Provider | null;
replayJson: string | null;
};
const initialState: SliceState = {
files: [],
initialPrompt: null,
selectedRepository: null,
selectedRepositoryProvider: null,
replayJson: null,
};
export const selectedFilesSlice = createSlice({
name: "initialQuery",
initialState,
reducers: {
addFile(state, action: PayloadAction<string>) {
state.files.push(action.payload);
},
removeFile(state, action: PayloadAction<number>) {
state.files.splice(action.payload, 1);
},
clearFiles(state) {
state.files = [];
},
setInitialPrompt(state, action: PayloadAction<string>) {
state.initialPrompt = action.payload;
},
clearInitialPrompt(state) {
state.initialPrompt = null;
},
setSelectedRepository(state, action: PayloadAction<GitRepository | null>) {
state.selectedRepository = action.payload;
},
clearSelectedRepository(state) {
state.selectedRepository = null;
},
setReplayJson(state, action: PayloadAction<string | null>) {
state.replayJson = action.payload;
},
},
});
export const {
addFile,
removeFile,
clearFiles,
setInitialPrompt,
clearInitialPrompt,
setSelectedRepository,
clearSelectedRepository,
setReplayJson,
} = selectedFilesSlice.actions;
export default selectedFilesSlice.reducer;
-35
View File
@@ -1,35 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface MetricsState {
cost: number | null;
max_budget_per_task: number | null;
usage: {
prompt_tokens: number;
completion_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
context_window: number;
per_turn_token: number;
} | null;
}
const initialState: MetricsState = {
cost: null,
max_budget_per_task: null,
usage: null,
};
const metricsSlice = createSlice({
name: "metrics",
initialState,
reducers: {
setMetrics: (state, action: PayloadAction<MetricsState>) => {
state.cost = action.payload.cost;
state.max_budget_per_task = action.payload.max_budget_per_task;
state.usage = action.payload.usage;
},
},
});
export const { setMetrics } = metricsSlice.actions;
export default metricsSlice.reducer;
-25
View File
@@ -1,25 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { StatusMessage } from "#/types/message";
const initialStatusMessage: StatusMessage = {
status_update: true,
type: "info",
id: "",
message: "",
};
export const statusSlice = createSlice({
name: "status",
initialState: {
curStatusMessage: initialStatusMessage,
},
reducers: {
setCurStatusMessage: (state, action: PayloadAction<StatusMessage>) => {
state.curStatusMessage = action.payload;
},
},
});
export const { setCurStatusMessage } = statusSlice.actions;
export default statusSlice.reducer;
+20
View File
@@ -0,0 +1,20 @@
import { create } from "zustand";
import { StatusMessage } from "#/types/message";
const initialStatusMessage: StatusMessage = {
status_update: true,
type: "info",
id: "",
message: "",
};
interface StatusState {
curStatusMessage: StatusMessage;
setCurStatusMessage: (message: StatusMessage) => void;
}
export const useStatusStore = create<StatusState>((set) => ({
curStatusMessage: initialStatusMessage,
setCurStatusMessage: (message: StatusMessage) =>
set({ curStatusMessage: message }),
}));
-10
View File
@@ -1,29 +1,19 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import agentReducer from "./state/agent-slice";
import browserReducer from "./state/browser-slice";
import codeReducer from "./state/code-slice";
import fileStateReducer from "./state/file-state-slice";
import initialQueryReducer from "./state/initial-query-slice";
import commandReducer from "./state/command-slice";
import { jupyterReducer } from "./state/jupyter-slice";
import securityAnalyzerReducer from "./state/security-analyzer-slice";
import statusReducer from "./state/status-slice";
import metricsReducer from "./state/metrics-slice";
import microagentManagementReducer from "./state/microagent-management-slice";
import conversationReducer from "./state/conversation-slice";
import eventMessageReducer from "./state/event-message-slice";
export const rootReducer = combineReducers({
fileState: fileStateReducer,
initialQuery: initialQueryReducer,
browser: browserReducer,
code: codeReducer,
cmd: commandReducer,
agent: agentReducer,
jupyter: jupyterReducer,
securityAnalyzer: securityAnalyzerReducer,
status: statusReducer,
metrics: metricsReducer,
microagentManagement: microagentManagementReducer,
conversation: conversationReducer,
eventMessage: eventMessageReducer,
@@ -0,0 +1,85 @@
import { create } from "zustand";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
interface InitialQueryState {
files: string[]; // base64 encoded images
initialPrompt: string | null;
selectedRepository: GitRepository | null;
selectedRepositoryProvider: Provider | null;
replayJson: string | null;
}
interface InitialQueryActions {
addFile: (file: string) => void;
removeFile: (index: number) => void;
clearFiles: () => void;
setInitialPrompt: (prompt: string) => void;
clearInitialPrompt: () => void;
setSelectedRepository: (repository: GitRepository | null) => void;
clearSelectedRepository: () => void;
setSelectedRepositoryProvider: (provider: Provider | null) => void;
setReplayJson: (replayJson: string | null) => void;
reset: () => void;
}
type InitialQueryStore = InitialQueryState & InitialQueryActions;
const initialState: InitialQueryState = {
files: [],
initialPrompt: null,
selectedRepository: null,
selectedRepositoryProvider: null,
replayJson: null,
};
export const useInitialQueryStore = create<InitialQueryStore>((set) => ({
...initialState,
addFile: (file: string) =>
set((state) => ({
files: [...state.files, file],
})),
removeFile: (index: number) =>
set((state) => ({
files: state.files.filter((_, i) => i !== index),
})),
clearFiles: () =>
set(() => ({
files: [],
})),
setInitialPrompt: (prompt: string) =>
set(() => ({
initialPrompt: prompt,
})),
clearInitialPrompt: () =>
set(() => ({
initialPrompt: null,
})),
setSelectedRepository: (repository: GitRepository | null) =>
set(() => ({
selectedRepository: repository,
})),
clearSelectedRepository: () =>
set(() => ({
selectedRepository: null,
})),
setSelectedRepositoryProvider: (provider: Provider | null) =>
set(() => ({
selectedRepositoryProvider: provider,
})),
setReplayJson: (replayJson: string | null) =>
set(() => ({
replayJson,
})),
reset: () => set(() => initialState),
}));
+27
View File
@@ -0,0 +1,27 @@
import { create } from "zustand";
interface MetricsState {
cost: number | null;
max_budget_per_task: number | null;
usage: {
prompt_tokens: number;
completion_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
context_window: number;
per_turn_token: number;
} | null;
}
interface MetricsStore extends MetricsState {
setMetrics: (metrics: MetricsState) => void;
}
const useMetricsStore = create<MetricsStore>((set) => ({
cost: null,
max_budget_per_task: null,
usage: null,
setMetrics: (metrics) => set(metrics),
}));
export default useMetricsStore;
+14
View File
@@ -5,6 +5,7 @@ const typographyVariants = cva("", {
variants: {
variant: {
h1: "text-[32px] text-white font-bold leading-5",
h3: "text-sm font-semibold text-gray-300",
span: "text-sm font-normal text-white leading-5.5",
codeBlock:
"font-mono text-sm leading-relaxed text-gray-300 whitespace-pre-wrap",
@@ -52,6 +53,18 @@ export function H1({
);
}
export function H3({
className,
testId,
children,
}: Omit<TypographyProps, "variant">) {
return (
<Typography variant="h3" className={className} testId={testId}>
{children}
</Typography>
);
}
export function Text({
className,
testId,
@@ -78,5 +91,6 @@ export function CodeBlock({
// Attach components to Typography for the expected API
Typography.H1 = H1;
Typography.H3 = H3;
Typography.Text = Text;
Typography.CodeBlock = CodeBlock;
+35
View File
@@ -474,3 +474,38 @@ export const getConversationStatusLabel = (
return "COMMON$UNKNOWN";
}
};
// Task Tracking Utility Functions
/**
* Get the status icon for a task status
* @param status The task status
* @returns The emoji icon for the status
*/
export const getStatusIcon = (status: string) => {
switch (status) {
case "todo":
return "⏳";
case "in_progress":
return "🔄";
case "done":
return "✅";
default:
return "❓";
}
};
/**
* Get the CSS class names for a task status badge
* @param status The task status
* @returns The CSS class names for styling the status badge
*/
export const getStatusClassName = (status: string) => {
if (status === "done") {
return "bg-green-800 text-green-200";
}
if (status === "in_progress") {
return "bg-yellow-800 text-yellow-200";
}
return "bg-gray-700 text-gray-300";
};
+22
View File
@@ -79,6 +79,28 @@ export function renderWithProviders(
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
// Export a render function for components that only need QueryClient and i18next providers
// (without Redux store)
export function renderWithQueryAndI18n(
ui: React.ReactElement,
renderOptions: Omit<RenderOptions, "wrapper"> = {},
) {
function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
export const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
-8
View File
@@ -161,7 +161,6 @@ class EventStream(EventStore):
self._clean_up_subscriber(subscriber_id, callback_id)
def add_event(self, event: Event, source: EventSource) -> None:
logger.info(f'Adding event with ID {event.id}')
if event.id != Event.INVALID_ID:
raise ValueError(
f'Event already has an ID:{event.id}. It was probably added back to the EventStream from inside a handler, triggering a loop.'
@@ -184,7 +183,6 @@ class EventStream(EventStore):
if len(current_write_page) == self.cache_size:
self._write_page_cache = []
logger.info(f'Event now has ID {event.id}')
if event.id is not None:
# Write the event to the store - this can take some time
event_json = json.dumps(data)
@@ -206,18 +204,12 @@ class EventStream(EventStore):
def _store_cache_page(self, current_write_page: list[dict]):
"""Store a page in the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
logger.info(
f'Writing event cache if page len {len(current_write_page)} is greater than {self.cache_size}'
)
if len(current_write_page) < self.cache_size:
return
start = current_write_page[0]['id']
end = start + self.cache_size
contents = json.dumps(current_write_page)
cache_filename = self._get_filename_for_cache(start, end)
logger.info(
f'writing event cache to {cache_filename} in file store of type {type(self.file_store)}'
)
self.file_store.write(cache_filename, contents)
def set_secrets(self, secrets: dict[str, str]) -> None:
+4 -1
View File
@@ -188,7 +188,10 @@ class ProviderHandler:
return SecretStr(data.token)
except Exception as e:
logger.warning(f'Failed to fetch latest token for provider {provider}: {e}')
logger.error(
f'Failed to fetch latest token for provider {provider}: {e}',
exc_info=True,
)
return None
+10 -3
View File
@@ -340,10 +340,16 @@ class Runtime(FileEditRuntimeMixin):
sid=self.sid,
)
logger.info(f'Fetching latest provider tokens for runtime: {self.sid}')
logger.info(
f'Fetching latest provider tokens for runtime: {self.sid}, '
f'providers: {providers_called}'
)
env_vars = await provider_handler.get_env_vars(
providers=providers_called, expose_secrets=False, get_latest=True
)
logger.info(
f'Successfully fetched {len(env_vars)} token(s) for runtime: {self.sid}'
)
if len(env_vars) == 0:
return
@@ -355,8 +361,9 @@ class Runtime(FileEditRuntimeMixin):
)
self.add_env_vars(provider_handler.expose_env_vars(env_vars))
except Exception as e:
logger.warning(
f'Failed export latest github token to runtime: {self.sid}, {e}'
logger.error(
f'Failed to export latest github token to runtime: {self.sid}, {e}',
exc_info=True,
)
async def _handle_action(self, event: Action) -> None:
@@ -129,15 +129,11 @@ class ActionExecutionClient(Runtime):
return send_request(self.session, method, url, **kwargs)
def check_if_alive(self) -> None:
request_url = f'{self.action_execution_server_url}/alive'
self.log('debug', f'Sending request to: {request_url}')
response = self._send_action_server_request(
'GET',
request_url,
f'{self.action_execution_server_url}/alive',
timeout=5,
)
self.log('debug', f'Response status code: {response.status_code}')
self.log('debug', f'Response text: {response.text}')
assert response.is_closed
def list_files(self, path: str | None = None) -> list[str]:
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik"
```
#### Additional Kubernetes Options
@@ -415,19 +415,11 @@ class RemoteRuntime(ActionExecutionClient):
def _wait_until_alive_impl(self) -> None:
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
self.log(
'debug',
f'Sending request to: {self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
)
runtime_info_response = self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
)
runtime_data = runtime_info_response.json()
self.log(
'debug',
f'received response: {runtime_data}',
)
assert 'runtime_id' in runtime_data
assert runtime_data['runtime_id'] == self.runtime_id
assert 'pod_status' in runtime_data
+1 -10
View File
@@ -13,15 +13,6 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.utils.shutdown_listener import should_continue
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
class JupyterRequirement(PluginRequirement):
@@ -45,7 +36,7 @@ class JupyterPlugin(Plugin):
if not is_local_runtime:
# Non-LocalRuntime
prefix = f'su - {username} -s ' if SU_TO_USER else ''
prefix = f'su - {username} -s '
# cd to code repo, setup all env vars and run micromamba
poetry_prefix = (
'cd /openhands/code\n'
+8 -24
View File
@@ -15,16 +15,6 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils.system import check_port_available
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
@dataclass
class VSCodeRequirement(PluginRequirement):
@@ -47,7 +37,7 @@ class VSCodePlugin(Plugin):
)
return
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
if username not in ['root', 'openhands']:
self.vscode_port = None
self.vscode_connection_token = None
logger.warning(
@@ -93,19 +83,13 @@ class VSCodePlugin(Plugin):
if path_mode:
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
cmd = (
(
f"su - {username} -s /bin/bash << 'EOF'\n"
if SU_TO_USER
else "/bin/bash << 'EOF'\n"
)
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
+ f'cd {workspace_path}\n'
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
+ 'EOF'
)
cmd = (
f"su - {username} -s /bin/bash << 'EOF'\n"
f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
f'cd {workspace_path}\n'
f'exec /openhands/.openvscode-server/bin/openvscode-server --host 0.0.0.0 --connection-token {self.vscode_connection_token} --port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
'EOF'
)
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
# to avoid ASYNC101 linting error
+1 -13
View File
@@ -20,16 +20,6 @@ from openhands.events.observation.commands import (
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
def split_bash_commands(commands: str) -> list[str]:
if not commands.strip():
@@ -203,9 +193,7 @@ class BashSession:
def initialize(self) -> None:
self.server = libtmux.Server()
_shell_command = '/bin/bash'
if SU_TO_USER and self.username in list(
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
):
if self.username in ['root', 'openhands']:
# This starts a non-login (new) shell for the given user
_shell_command = f'su {self.username} -'
+4 -17
View File
@@ -1,5 +1,3 @@
import os
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins import PluginRequirement
@@ -14,9 +12,6 @@ DEFAULT_PYTHON_PREFIX = [
]
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
RUNTIME_UID = os.getenv('RUNTIME_UID')
def get_action_execution_server_startup_command(
server_port: int,
@@ -31,10 +26,7 @@ def get_action_execution_server_startup_command(
sandbox_config = app_config.sandbox
logger.debug(f'app_config {vars(app_config)}')
logger.debug(f'sandbox_config {vars(sandbox_config)}')
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
logger.debug(
f'override_username {override_username}, override_user_id {override_user_id}'
)
logger.debug(f'override_user_id {override_user_id}')
# Plugin args
plugin_args = []
@@ -48,15 +40,10 @@ def get_action_execution_server_startup_command(
'--browsergym-eval-env'
] + sandbox_config.browsergym_eval_env.split(' ')
username = (
override_username
or RUNTIME_USERNAME
or ('openhands' if app_config.run_as_openhands else 'root')
username = override_username or (
'openhands' if app_config.run_as_openhands else 'root'
)
user_id = (
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
)
logger.debug(f'username {username}, user_id {user_id}')
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
base_cmd = [
*python_prefix,
-9
View File
@@ -1,5 +1,4 @@
import threading
import traceback
from typing import Optional
import httpx
@@ -63,11 +62,6 @@ class BatchedWebHookFileStore(FileStore):
batch_size_limit_bytes: Size limit in bytes after which a batch is sent.
If None, uses the default constant WEBHOOK_BATCH_SIZE_LIMIT_BYTES.
"""
logger.info(
f'BatchedWebHookFileStore __init__ called with filestore type {type(file_store)}'
)
stack = '\n'.join(traceback.format_stack())
logger.info('BatchedWebHookFileStore __init__ stack trace:\n%s', stack)
self.file_store = file_store
self.base_url = base_url
if client is None:
@@ -95,9 +89,6 @@ class BatchedWebHookFileStore(FileStore):
path: The path to write to
contents: The contents to write
"""
logger.info(
f'BatchedWebHookFileStore write to {path} in filestore of type {type(self.file_store)}'
)
self.file_store.write(path, contents)
self._queue_update(path, 'write', contents)
-4
View File
@@ -1,11 +1,9 @@
import os
import traceback
from typing import Any, TypedDict
import boto3
import botocore
from openhands.core.logger import openhands_logger as logger
from openhands.storage.files import FileStore
@@ -39,8 +37,6 @@ class S3FileStore(FileStore):
)
def write(self, path: str, contents: str | bytes) -> None:
stack = '\n'.join(traceback.format_stack())
logger.info('S3FileStore write stack trace:\n%s', stack)
try:
as_bytes = (
contents.encode('utf-8') if isinstance(contents, str) else contents
-1
View File
@@ -27,7 +27,6 @@ class HttpSession:
headers = kwargs.get('headers') or {}
headers = {**self.headers, **headers}
kwargs['headers'] = headers
logger.debug(f'HttpSession:request called with args {args} and kwargs {kwargs}')
return CLIENT.request(*args, **kwargs)
def stream(self, *args, **kwargs):
Generated
+134 -108
View File
@@ -2453,19 +2453,19 @@ files = [
[[package]]
name = "fastapi"
version = "0.116.1"
version = "0.116.2"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
{file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
{file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
{file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.48.0"
starlette = ">=0.40.0,<0.49.0"
typing-extensions = ">=4.8.0"
[package.extras]
@@ -6883,101 +6883,126 @@ ptyprocess = ">=0.5"
[[package]]
name = "pillow"
version = "11.2.1"
version = "11.3.0"
description = "Python Imaging Library (Fork)"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "test"]
files = [
{file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"},
{file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"},
{file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"},
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"},
{file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"},
{file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"},
{file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"},
{file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"},
{file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"},
{file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"},
{file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"},
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"},
{file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"},
{file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"},
{file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"},
{file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"},
{file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"},
{file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"},
{file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"},
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"},
{file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"},
{file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"},
{file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"},
{file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"},
{file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"},
{file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"},
{file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"},
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"},
{file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"},
{file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"},
{file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"},
{file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"},
{file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"},
{file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"},
{file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"},
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"},
{file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"},
{file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"},
{file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"},
{file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"},
{file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"},
{file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"},
{file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"},
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"},
{file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"},
{file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"},
{file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"},
{file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"},
{file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"},
{file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"},
{file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"},
{file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"},
{file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"},
{file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"},
{file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"},
{file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"},
{file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"},
{file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"},
{file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"},
{file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"},
{file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"},
{file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"},
{file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"},
{file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"},
{file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"},
{file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"},
{file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"},
{file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"},
{file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"},
{file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"},
{file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"},
{file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"},
{file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"},
{file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"},
{file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"},
{file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"},
{file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"},
{file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"},
{file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"},
{file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"},
{file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"},
{file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"},
{file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"},
{file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"},
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"},
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"},
{file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"},
{file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"},
{file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"},
{file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"},
{file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"},
{file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"},
{file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"},
{file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"},
{file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"},
{file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"},
{file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"},
{file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"},
{file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"},
{file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"},
{file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"},
{file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"},
{file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"},
{file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"},
{file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"},
{file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"},
{file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"},
{file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"},
{file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"},
{file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"},
{file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"},
{file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"},
{file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"},
{file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"},
{file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"},
{file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"},
{file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"},
{file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"},
{file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"},
{file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"},
{file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"},
{file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"},
{file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"},
{file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"},
{file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"},
{file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"},
{file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"},
{file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"},
{file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"},
{file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"},
{file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"},
{file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"},
{file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"},
{file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"},
{file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"},
{file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"},
{file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"},
{file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"},
{file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"},
{file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"},
{file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"},
{file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"},
{file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"},
{file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"},
{file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
typing = ["typing-extensions ; python_version < \"3.10\""]
xmp = ["defusedxml"]
@@ -7828,14 +7853,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "5.6.0"
version = "6.0.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-5.6.0-py3-none-any.whl", hash = "sha256:ca6bf446bfb0a2d8d71d6d6bb860798d864c36a29b3d9ae8d7fc7958c59f88e7"},
{file = "pypdf-5.6.0.tar.gz", hash = "sha256:a4b6538b77fc796622000db7127e4e58039ec5e6afd292f8e9bf42e2e985a749"},
{file = "pypdf-6.0.0-py3-none-any.whl", hash = "sha256:56ea60100ce9f11fc3eec4f359da15e9aec3821b036c1f06d2b660d35683abb8"},
{file = "pypdf-6.0.0.tar.gz", hash = "sha256:282a99d2cc94a84a3a3159f0d9358c0af53f85b4d28d76ea38b96e9e5ac2a08d"},
]
[package.extras]
@@ -8791,19 +8816,19 @@ shaping = ["uharfbuzz"]
[[package]]
name = "requests"
version = "2.32.3"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main", "evaluation", "runtime", "test"]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""}
urllib3 = ">=1.21.1,<3"
@@ -9592,14 +9617,14 @@ whisper-local = ["openai-whisper", "soundfile"]
[[package]]
name = "sse-starlette"
version = "2.4.1"
version = "3.0.2"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"},
{file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"},
{file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"},
{file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"},
]
[package.dependencies]
@@ -9607,7 +9632,7 @@ anyio = ">=4.7.0"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
@@ -9663,18 +9688,19 @@ files = [
[[package]]
name = "starlette"
version = "0.46.2"
version = "0.48.0"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
{file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
{file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
@@ -10677,14 +10703,14 @@ files = [
[[package]]
name = "urllib3"
version = "2.4.0"
version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation", "runtime", "test"]
files = [
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
@@ -11823,4 +11849,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "6c7bc9a39d6875e09966872a5d579e73b5cb739d1bad89d3a7dde829541cec16"
content-hash = "5914d56ec4c81b3a1e4ba141a89233c287a435f358d67be496a6f1230c1e1610"
+9 -2
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.56.0"
version = "0.57.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -65,7 +65,7 @@ pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "0.3.2"
python-socketio = "^5.11.4"
sse-starlette = "^2.1.3"
sse-starlette = "^3.0.2"
psutil = "*"
python-json-logger = "^3.2.1"
prompt-toolkit = "^3.0.50"
@@ -84,6 +84,13 @@ pylatexenc = "*"
python-docx = "*"
bashlex = "^0.18"
# Explicitly pinned packages for latest versions
pypdf = "^6.0.0"
pillow = "^11.3.0"
starlette = "^0.48.0"
urllib3 = "^2.5.0"
requests = "^2.32.5"
# TODO: These are integrations that should probably be optional
redis = ">=5.2,<7.0"
+290
View File
@@ -0,0 +1,290 @@
"""
Unit tests to verify pricing documentation consistency.
"""
import re
from pathlib import Path
from typing import Any
import pytest
import requests
class TestPricingDocumentation:
"""Test class for pricing documentation consistency."""
@pytest.fixture
def pricing_data(self) -> dict[str, Any]:
"""Fetch pricing data from LiteLLM repository."""
url = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
response = requests.get(url)
response.raise_for_status()
return response.json()
@pytest.fixture
def openhands_models(self) -> list[str]:
"""Get the list of OpenHands models from the codebase."""
# Read the models directly from the source code file
llm_utils_path = (
Path(__file__).parent.parent.parent / 'openhands' / 'utils' / 'llm.py'
)
content = llm_utils_path.read_text()
# Extract the openhands_models list from the file
import ast
# Parse the Python file
tree = ast.parse(content)
# Find the openhands_models assignment
for node in ast.walk(tree):
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == 'openhands_models'
):
# Extract the list values
if isinstance(node.value, ast.List):
models = []
for elt in node.value.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
# Remove 'openhands/' prefix and filter out secret models
model = elt.value
if model.startswith('openhands/'):
model = model[10:] # Remove 'openhands/' prefix
if not model.startswith('<secret'):
models.append(model)
return models
# Fallback if parsing fails
raise ValueError('Could not extract openhands_models from llm.py')
@pytest.fixture
def documentation_content(self) -> str:
"""Read the OpenHands LLM documentation content."""
docs_path = (
Path(__file__).parent.parent.parent
/ 'docs'
/ 'usage'
/ 'llms'
/ 'openhands-llms.mdx'
)
return docs_path.read_text()
def extract_pricing_from_docs(self, content: str) -> dict[str, dict[str, float]]:
"""Extract pricing information from documentation."""
# Updated pattern to handle cached input cost column (which can be N/A)
pricing_table_pattern = (
r'\| ([^|]+) \| \$([0-9.]+) \| ([^|]+) \| \$([0-9.]+) \|'
)
matches = re.findall(pricing_table_pattern, content)
pricing_data = {}
for match in matches:
model_name = match[0].strip()
input_cost = float(match[1])
cached_input_str = match[2].strip()
output_cost = float(match[3])
# Parse cached input cost (can be N/A or $X.XX)
cached_input_cost = None
if cached_input_str != 'N/A':
cached_input_cost = float(cached_input_str.replace('$', ''))
pricing_data[model_name] = {
'input_cost_per_million_tokens': input_cost,
'cached_input_cost_per_million_tokens': cached_input_cost,
'output_cost_per_million_tokens': output_cost,
}
return pricing_data
def get_litellm_pricing(
self, model: str, pricing_data: dict[str, Any]
) -> dict[str, float]:
"""Get pricing for a model from LiteLLM data."""
# Try different variations of the model name
variations = [
model,
f'openai/{model}',
f'anthropic/{model}',
f'google/{model}',
f'mistral/{model}',
]
for variation in variations:
if variation in pricing_data:
model_data = pricing_data[variation]
result = {
'input_cost_per_million_tokens': model_data.get(
'input_cost_per_token', 0
)
* 1_000_000,
'output_cost_per_million_tokens': model_data.get(
'output_cost_per_token', 0
)
* 1_000_000,
}
# Add cached input cost if available
cached_cost = model_data.get('cache_read_input_token_cost', 0)
if cached_cost > 0:
result['cached_input_cost_per_million_tokens'] = (
cached_cost * 1_000_000
)
return result
return {}
def test_pricing_table_exists(self, documentation_content: str):
"""Test that the pricing table exists in the documentation."""
assert (
'| Model | Input Cost (per 1M tokens) | Cached Input Cost (per 1M tokens) | Output Cost (per 1M tokens)'
in documentation_content
)
assert 'claude-opus-4-20250514' in documentation_content
assert 'qwen3-coder-480b' in documentation_content
def test_no_external_json_link(self, documentation_content: str):
"""Test that the external JSON link has been removed."""
assert (
'github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json'
not in documentation_content
)
def test_pricing_consistency_with_litellm(
self, pricing_data: dict[str, Any], documentation_content: str
):
"""Test that pricing in documentation matches LiteLLM data where applicable."""
docs_pricing = self.extract_pricing_from_docs(documentation_content)
# Special case for qwen3-coder-480b (custom pricing)
qwen_pricing = docs_pricing.get('qwen3-coder-480b')
assert qwen_pricing is not None
assert qwen_pricing['input_cost_per_million_tokens'] == 0.4
assert qwen_pricing['output_cost_per_million_tokens'] == 1.6
assert qwen_pricing['cached_input_cost_per_million_tokens'] is None # N/A
# Test other models against LiteLLM data
for model_name, doc_pricing in docs_pricing.items():
if model_name == 'qwen3-coder-480b':
continue # Skip custom pricing model
litellm_pricing = self.get_litellm_pricing(model_name, pricing_data)
if litellm_pricing: # Only test if we found pricing in LiteLLM
assert (
abs(
doc_pricing['input_cost_per_million_tokens']
- litellm_pricing['input_cost_per_million_tokens']
)
< 0.01
), (
f'Input pricing mismatch for {model_name}: docs={doc_pricing["input_cost_per_million_tokens"]}, litellm={litellm_pricing["input_cost_per_million_tokens"]}'
)
assert (
abs(
doc_pricing['output_cost_per_million_tokens']
- litellm_pricing['output_cost_per_million_tokens']
)
< 0.01
), (
f'Output pricing mismatch for {model_name}: docs={doc_pricing["output_cost_per_million_tokens"]}, litellm={litellm_pricing["output_cost_per_million_tokens"]}'
)
# Test cached input cost if both have it
doc_cached = doc_pricing.get('cached_input_cost_per_million_tokens')
litellm_cached = litellm_pricing.get(
'cached_input_cost_per_million_tokens'
)
if doc_cached is not None and litellm_cached is not None:
assert abs(doc_cached - litellm_cached) < 0.01, (
f'Cached input pricing mismatch for {model_name}: docs={doc_cached}, litellm={litellm_cached}'
)
elif doc_cached is None and litellm_cached is not None:
# Documentation shows N/A but LiteLLM has cached pricing - this might be intentional
pass
elif doc_cached is not None and litellm_cached is None:
# Documentation has cached pricing but LiteLLM doesn't - this shouldn't happen
raise AssertionError(
f'Documentation has cached pricing for {model_name} but LiteLLM does not'
)
def test_all_openhands_models_documented(
self, openhands_models: list[str], documentation_content: str
):
"""Test that all OpenHands models are documented in the pricing table."""
docs_pricing = self.extract_pricing_from_docs(documentation_content)
documented_models = set(docs_pricing.keys())
# Filter out models that might not have pricing (like kimi-k2-0711-preview)
expected_models = set(openhands_models)
# Check that most models are documented (allowing for some models without pricing)
documented_count = len(documented_models.intersection(expected_models))
total_count = len(expected_models)
# We should have at least 80% of models documented
coverage_ratio = documented_count / total_count if total_count > 0 else 0
assert coverage_ratio >= 0.8, (
f'Only {documented_count}/{total_count} models documented in pricing table'
)
def test_model_list_consistency(
self, openhands_models: list[str], documentation_content: str
):
"""Test that the model list in documentation is consistent with the code."""
docs_pricing = self.extract_pricing_from_docs(documentation_content)
documented_models = set(docs_pricing.keys())
code_models = set(openhands_models)
# Find models that are in code but not in docs
missing_from_docs = code_models - documented_models
# Find models that are in docs but not in code
extra_in_docs = documented_models - code_models
# Allow some models to be missing from docs (e.g., if they don't have pricing)
# but no extra models should be in docs that aren't in code
assert not extra_in_docs, (
f'Models in documentation but not in code: {extra_in_docs}'
)
# Report missing models for visibility (but don't fail the test)
if missing_from_docs:
print(f'Models in code but not documented: {missing_from_docs}')
def test_pricing_format_consistency(self, documentation_content: str):
"""Test that pricing format is consistent in the documentation."""
docs_pricing = self.extract_pricing_from_docs(documentation_content)
for model_name, pricing in docs_pricing.items():
# Check that prices are reasonable (not negative, not extremely high)
assert pricing['input_cost_per_million_tokens'] >= 0, (
f'Negative input cost for {model_name}'
)
assert pricing['output_cost_per_million_tokens'] >= 0, (
f'Negative output cost for {model_name}'
)
assert pricing['input_cost_per_million_tokens'] <= 100, (
f'Unreasonably high input cost for {model_name}'
)
assert pricing['output_cost_per_million_tokens'] <= 200, (
f'Unreasonably high output cost for {model_name}'
)
# Output cost should generally be higher than input cost
if pricing['input_cost_per_million_tokens'] > 0:
ratio = (
pricing['output_cost_per_million_tokens']
/ pricing['input_cost_per_million_tokens']
)
assert ratio >= 1.0, (
f'Output cost should be >= input cost for {model_name}'
)
assert ratio <= 20.0, (
f'Output/input cost ratio too high for {model_name}'
)