mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef6824327 | |||
| c325630920 | |||
| b466f61b98 | |||
| 085c1ca591 |
@@ -1,31 +1,12 @@
|
||||
## Summary of PR
|
||||
- [ ] This change is worth documenting at https://docs.all-hands.dev/
|
||||
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
|
||||
|
||||
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
|
||||
**End-user friendly description of the problem this fixes or functionality this introduces.**
|
||||
|
||||
## Change Type
|
||||
|
||||
<!-- Choose the types that apply to your PR and remove the rest. -->
|
||||
---
|
||||
**Summarize what the PR does, explaining any non-trivial design decisions.**
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
---
|
||||
**Link of any specific issues this addresses:**
|
||||
|
||||
@@ -132,10 +132,8 @@ class JiraExistingConversationView(JiraViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -135,10 +135,8 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -132,10 +132,8 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -263,10 +263,8 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
# Check if conversation has been deleted
|
||||
# Update logic when soft delete is implemented
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
Generated
+18
-3925
File diff suppressed because one or more lines are too long
@@ -1,123 +0,0 @@
|
||||
"""
|
||||
Cookie compression utilities for keycloak_auth cookie.
|
||||
|
||||
This module provides functions to compress and decompress cookie data
|
||||
to reduce cookie size and improve performance.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def compress_cookie_data(data: str) -> str:
|
||||
"""
|
||||
Compress cookie data using gzip and encode with base64.
|
||||
|
||||
Args:
|
||||
data: The cookie data string to compress
|
||||
|
||||
Returns:
|
||||
Base64 encoded compressed data with 'gz:' prefix to indicate compression
|
||||
|
||||
Raises:
|
||||
Exception: If compression fails
|
||||
"""
|
||||
try:
|
||||
# Convert string to bytes
|
||||
data_bytes = data.encode('utf-8')
|
||||
|
||||
# Compress using gzip
|
||||
compressed_bytes = gzip.compress(data_bytes, compresslevel=6)
|
||||
|
||||
# Encode with base64 for safe cookie storage
|
||||
encoded_data = base64.b64encode(compressed_bytes).decode('ascii')
|
||||
|
||||
# Add prefix to indicate this is compressed data
|
||||
compressed_cookie = f'gz:{encoded_data}'
|
||||
|
||||
logger.debug(
|
||||
'Cookie compression stats',
|
||||
extra={
|
||||
'original_size': len(data),
|
||||
'compressed_size': len(compressed_cookie),
|
||||
'compression_ratio': len(compressed_cookie) / len(data)
|
||||
if len(data) > 0
|
||||
else 0,
|
||||
},
|
||||
)
|
||||
|
||||
return compressed_cookie
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to compress cookie data: {str(e)}')
|
||||
raise
|
||||
|
||||
|
||||
def decompress_cookie_data(data: str) -> str:
|
||||
"""
|
||||
Decompress cookie data if it's compressed, otherwise return as-is.
|
||||
|
||||
Args:
|
||||
data: The cookie data string (may be compressed or uncompressed)
|
||||
|
||||
Returns:
|
||||
Decompressed cookie data string
|
||||
|
||||
Raises:
|
||||
Exception: If decompression fails for compressed data
|
||||
"""
|
||||
try:
|
||||
# Check if data is compressed (has 'gz:' prefix)
|
||||
if not data.startswith('gz:'):
|
||||
# Not compressed, return as-is for backward compatibility
|
||||
logger.debug('Cookie data is not compressed, returning as-is')
|
||||
return data
|
||||
|
||||
# Remove the 'gz:' prefix
|
||||
encoded_data = data[3:]
|
||||
|
||||
# Check for empty compressed data
|
||||
if not encoded_data:
|
||||
raise ValueError('Empty compressed data')
|
||||
|
||||
# Decode from base64
|
||||
compressed_bytes = base64.b64decode(encoded_data.encode('ascii'))
|
||||
|
||||
# Decompress using gzip
|
||||
decompressed_bytes = gzip.decompress(compressed_bytes)
|
||||
|
||||
# Convert back to string
|
||||
decompressed_data = decompressed_bytes.decode('utf-8')
|
||||
|
||||
logger.debug(
|
||||
'Cookie decompression stats',
|
||||
extra={
|
||||
'compressed_size': len(data),
|
||||
'decompressed_size': len(decompressed_data),
|
||||
'compression_ratio': len(data) / len(decompressed_data)
|
||||
if len(decompressed_data) > 0
|
||||
else 0,
|
||||
},
|
||||
)
|
||||
|
||||
return decompressed_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to decompress cookie data: {str(e)}')
|
||||
raise
|
||||
|
||||
|
||||
def should_compress_cookie(data: str, min_size_threshold: int = 1000) -> bool:
|
||||
"""
|
||||
Determine if cookie data should be compressed based on size.
|
||||
|
||||
Args:
|
||||
data: The cookie data string
|
||||
min_size_threshold: Minimum size in bytes to consider compression
|
||||
|
||||
Returns:
|
||||
True if data should be compressed, False otherwise
|
||||
"""
|
||||
return len(data.encode('utf-8')) >= min_size_threshold
|
||||
@@ -13,7 +13,6 @@ from server.auth.auth_error import (
|
||||
ExpiredError,
|
||||
NoCredentialsError,
|
||||
)
|
||||
from server.auth.cookie_compression import decompress_cookie_data
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
@@ -272,18 +271,7 @@ async def saas_user_auth_from_cookie(request: Request) -> SaasUserAuth | None:
|
||||
signed_token = request.cookies.get('keycloak_auth')
|
||||
if not signed_token:
|
||||
return None
|
||||
|
||||
# Decompress the cookie data if it's compressed
|
||||
try:
|
||||
decompressed_token = decompress_cookie_data(signed_token)
|
||||
logger.debug('Cookie data decompressed successfully')
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to decompress cookie data, trying as uncompressed: {str(e)}'
|
||||
)
|
||||
decompressed_token = signed_token
|
||||
|
||||
return await saas_user_auth_from_signed_token(decompressed_token)
|
||||
return await saas_user_auth_from_signed_token(signed_token)
|
||||
except Exception as exc:
|
||||
raise CookieError from exc
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from server.auth.auth_error import (
|
||||
NoCredentialsError,
|
||||
TosNotAcceptedError,
|
||||
)
|
||||
from server.auth.cookie_compression import decompress_cookie_data
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.routes.auth import (
|
||||
@@ -115,18 +114,8 @@ class SetAuthCookieMiddleware:
|
||||
jwt_secret: SecretStr = config.jwt_secret # type: ignore[assignment]
|
||||
if keycloak_auth_cookie:
|
||||
try:
|
||||
# Decompress the cookie data if it's compressed
|
||||
try:
|
||||
decompressed_cookie = decompress_cookie_data(keycloak_auth_cookie)
|
||||
logger.debug('Middleware: Cookie data decompressed successfully')
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'Middleware: Failed to decompress cookie data, trying as uncompressed: {str(e)}'
|
||||
)
|
||||
decompressed_cookie = keycloak_auth_cookie
|
||||
|
||||
decoded = jwt.decode(
|
||||
decompressed_cookie,
|
||||
keycloak_auth_cookie,
|
||||
jwt_secret.get_secret_value(),
|
||||
algorithms=['HS256'],
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ from server.auth.constants import (
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
)
|
||||
from server.auth.cookie_compression import compress_cookie_data, should_compress_cookie
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
@@ -56,24 +55,12 @@ def set_response_cookie(
|
||||
}
|
||||
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
|
||||
|
||||
# Compress the signed token if it's large enough to benefit from compression
|
||||
cookie_value = signed_token
|
||||
if should_compress_cookie(signed_token):
|
||||
try:
|
||||
cookie_value = compress_cookie_data(signed_token)
|
||||
logger.debug('Cookie data compressed successfully')
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to compress cookie data, using uncompressed: {str(e)}'
|
||||
)
|
||||
cookie_value = signed_token
|
||||
|
||||
# Set secure cookie with (potentially compressed) signed token
|
||||
# Set secure cookie with signed token
|
||||
domain = get_cookie_domain(request)
|
||||
if domain:
|
||||
response.set_cookie(
|
||||
key='keycloak_auth',
|
||||
value=cookie_value,
|
||||
value=signed_token,
|
||||
domain=domain,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
@@ -82,7 +69,7 @@ def set_response_cookie(
|
||||
else:
|
||||
response.set_cookie(
|
||||
key='keycloak_auth',
|
||||
value=cookie_value,
|
||||
value=signed_token,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite=get_cookie_samesite(request),
|
||||
|
||||
@@ -784,7 +784,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
|
||||
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
|
||||
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
|
||||
env_vars['ENABLE_V1'] = '0'
|
||||
|
||||
# We need this for LLM traces tracking to identify the source of the LLM calls
|
||||
env_vars['WEB_HOST'] = WEB_HOST
|
||||
|
||||
@@ -195,11 +195,14 @@ def update_active_working_seconds(
|
||||
file_store: The FileStore instance for accessing conversation data
|
||||
"""
|
||||
try:
|
||||
# Get all events for the conversation
|
||||
events = list(event_store.get_events())
|
||||
|
||||
# Track agent state changes and calculate running time
|
||||
running_start_time = None
|
||||
total_running_seconds = 0.0
|
||||
|
||||
for event in event_store.search_events():
|
||||
for event in events:
|
||||
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
|
||||
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from server.auth.auth_error import AuthError, ExpiredError
|
||||
from server.auth.cookie_compression import decompress_cookie_data
|
||||
from server.auth.saas_user_auth import saas_user_auth_from_signed_token
|
||||
from server.auth.token_manager import TokenManager
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
@@ -130,20 +129,8 @@ class SaasConversationValidator(ConversationValidator):
|
||||
if not config.jwt_secret:
|
||||
raise RuntimeError('JWT secret not found')
|
||||
|
||||
# Decompress the cookie data if it's compressed
|
||||
try:
|
||||
decompressed_token = decompress_cookie_data(signed_token)
|
||||
logger.debug(
|
||||
'Conversation validator: Cookie data decompressed successfully'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f'Conversation validator: Failed to decompress cookie data, trying as uncompressed: {str(e)}'
|
||||
)
|
||||
decompressed_token = signed_token
|
||||
|
||||
try:
|
||||
user_auth = await saas_user_auth_from_signed_token(decompressed_token)
|
||||
user_auth = await saas_user_auth_from_signed_token(signed_token)
|
||||
access_token = await user_auth.get_access_token()
|
||||
except ExpiredError:
|
||||
raise ConnectionRefusedError('SESSION$TIMEOUT_MESSAGE')
|
||||
|
||||
@@ -137,9 +137,7 @@ class TestJiraExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,9 +137,7 @@ class TestJiraDcExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,9 +137,7 @@ class TestLinearExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
events.append(event6)
|
||||
|
||||
# Configure the mock event store to return our test events
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -133,7 +133,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2]
|
||||
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -178,7 +178,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
events = [event1, event2, event3]
|
||||
# No final state change - agent still running
|
||||
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -221,7 +221,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2, event3]
|
||||
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -267,7 +267,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2, event3, event4]
|
||||
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -297,7 +297,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
user_id = 'test_user_error'
|
||||
|
||||
# Configure the mock to raise an exception
|
||||
mock_event_store.search_events.side_effect = Exception('Test error')
|
||||
mock_event_store.get_events.side_effect = Exception('Test error')
|
||||
|
||||
# Call the function under test
|
||||
update_active_working_seconds(
|
||||
@@ -376,7 +376,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
event10.timestamp = '1970-01-01T00:00:37.000000'
|
||||
events.append(event10)
|
||||
|
||||
mock_event_store.search_events.return_value = events
|
||||
mock_event_store.get_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
|
||||
@@ -307,7 +307,7 @@ class TheoremqaTask(Task):
|
||||
|
||||
# Converting the string answer to a number/list/bool/option
|
||||
try:
|
||||
prediction = ast.literal_eval(prediction)
|
||||
prediction = eval(prediction)
|
||||
except Exception:
|
||||
LOGGER.warning(
|
||||
f'[TASK] Failed to convert the answer: {prediction}\n{traceback.format_exc()}'
|
||||
|
||||
@@ -111,10 +111,15 @@ for run_idx in $(seq 1 $N_RUNS); do
|
||||
echo "### Evaluating on $OUTPUT_FILE ... ###"
|
||||
OUTPUT_CONFIG_FILE="${OUTPUT_FILE%.jsonl}_config.json"
|
||||
export EVAL_SKIP_BUILD_ERRORS=true
|
||||
pip install multi-swe-bench --quiet --disable-pip-version-check > /dev/null 2>&1
|
||||
COMMAND="poetry run python ./evaluation/benchmarks/multi_swe_bench/scripts/eval/update_multi_swe_bench_config.py --input $OUTPUT_FILE --output $OUTPUT_CONFIG_FILE --dataset $EVAL_DATASET;
|
||||
poetry run python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
|
||||
python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
|
||||
"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
echo "Running command: $COMMAND"
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
@@ -24,8 +24,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -166,8 +166,7 @@ def load_integration_tests() -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
args, _ = parser.parse_known_args()
|
||||
args = parse_arguments()
|
||||
integration_tests = load_integration_tests()
|
||||
|
||||
llm_config = None
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
} from "#/mocks/file-service-handlers";
|
||||
|
||||
/**
|
||||
* File service API tests. The actual API calls are mocked using MSW.
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("ConversationService File API", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id"),
|
||||
).resolves.toEqual(FILE_VARIANTS_1);
|
||||
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
ConversationService.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
|
||||
describe("buildWebSocketUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Basic URL construction", () => {
|
||||
it("should build WebSocket URL with conversation ID and URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use wss:// protocol when window.location.protocol is https:", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "https:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"https://example.com:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should extract host and port from conversation URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-456",
|
||||
"http://agent-server.com:9000/api/conversations/conv-456",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query parameters handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
expect(result).not.toContain("session_api_key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback to window.location.host", () => {
|
||||
it("should use window.location.host when conversation URL is null", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", null);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is undefined", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", undefined);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is relative path", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is invalid", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when conversationId is undefined", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
undefined,
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when conversationId is empty string", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle conversation URLs with non-standard ports", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com:12345/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation URLs without port (default port)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation IDs with special characters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123-abc_def",
|
||||
"http://localhost:8080/api/conversations/conv-123-abc_def",
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"ws://localhost:8080/sockets/events/conv-123-abc_def",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build URL without query parameters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-15
@@ -8,14 +8,6 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock the unified stop conversation hook
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedPauseConversationSandbox: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -81,7 +73,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStopConversationMutate.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
@@ -438,6 +430,19 @@ describe("ConversationPanel", () => {
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"stopConversation",
|
||||
);
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
conversation.status = "STOPPED";
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
@@ -460,12 +465,9 @@ describe("ConversationPanel", () => {
|
||||
screen.queryByRole("button", { name: /confirm/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the mutation was called
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "1",
|
||||
version: undefined,
|
||||
});
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
|
||||
// Verify the API was called
|
||||
expect(stopConversationSpy).toHaveBeenCalledWith("1");
|
||||
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should only show stop button for STARTING or RUNNING conversations", async () => {
|
||||
|
||||
@@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
|
||||
useStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
|
||||
useStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
@@ -41,19 +41,6 @@ vi.mock("#/hooks/use-user-providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
taskId: null,
|
||||
conversationId: "test-conversation-id",
|
||||
task: null,
|
||||
taskStatus: null,
|
||||
taskDetail: null,
|
||||
taskError: null,
|
||||
isLoadingTask: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -79,14 +66,12 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
// Helper function to mock agent store with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -100,42 +85,20 @@ describe("ServerStatus", () => {
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" />,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
rerender(<ServerStatus conversationStatus="STOPPED" />);
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
rerender(<ServerStatus conversationStatus="STARTING" />);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
rerender(<ServerStatus conversationStatus={null} />);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -145,13 +108,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -171,13 +128,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -197,13 +148,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -220,18 +165,12 @@ describe("ServerStatus", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleStop.mockClear();
|
||||
mockStopConversationMutate.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -239,25 +178,21 @@ describe("ServerStatus", () => {
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleResumeAgent.mockClear();
|
||||
mockStartConversationMutate.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -265,7 +200,10 @@ describe("ServerStatus", () => {
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
providers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
@@ -274,13 +212,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -289,7 +221,9 @@ describe("ServerStatus", () => {
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
@@ -298,13 +232,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -322,13 +250,7 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
|
||||
@@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation store
|
||||
@@ -57,11 +57,14 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
const onStopMock = vi.fn();
|
||||
|
||||
// Helper function to mock stores
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(useConversationStore).mockReturnValue({
|
||||
@@ -100,13 +103,14 @@ describe("InteractiveChatBox", () => {
|
||||
};
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) =>
|
||||
renderWithProviders(
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox {...props} />
|
||||
</MemoryRouter>,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = vi
|
||||
@@ -123,6 +127,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
@@ -135,6 +140,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textbox = screen.getByTestId("chat-input");
|
||||
@@ -151,6 +157,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// Create a larger file to ensure it passes validation
|
||||
@@ -177,6 +184,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
|
||||
@@ -201,6 +209,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
@@ -231,6 +240,7 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const button = screen.getByTestId("submit-button");
|
||||
@@ -240,14 +250,33 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the stop button when agent is running and call onStop when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.RUNNING);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// The stop button should be available when agent is running
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await user.click(stopButton);
|
||||
expect(onStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle image upload and message submission correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
mockStores(AgentState.AWAITING_USER_INPUT);
|
||||
|
||||
const { rerender } = renderInteractiveChatBox({
|
||||
onSubmit,
|
||||
onSubmit: onSubmit,
|
||||
onStop: onStop,
|
||||
});
|
||||
|
||||
// Verify text input has the initial value
|
||||
@@ -267,7 +296,7 @@ describe("InteractiveChatBox", () => {
|
||||
// Simulate parent component updating the value prop
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={onSubmit} />
|
||||
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -30,9 +30,11 @@ describe("JupyterEditor", () => {
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation ID hook
|
||||
@@ -50,9 +50,11 @@ describe("MicroagentsModal - Refresh Button", () => {
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
|
||||
// Mock the agent state to return a ready state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
// Mock the agent store to return a ready state
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { screen, waitFor, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
@@ -19,34 +19,16 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
// Helper function to render components with ConversationWebSocketProvider
|
||||
function renderWithWebSocketContext(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-default",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
|
||||
sessionApiKey: string | null = null,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -57,11 +39,7 @@ function renderWithWebSocketContext(
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={sessionApiKey}
|
||||
>
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -416,98 +394,4 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashActionEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context (we don't need a component, just need the provider to be active)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the command to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the command was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("input");
|
||||
expect(commands[0].content).toBe("npm test");
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
"npm test",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashObservationEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the output to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the output was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("output");
|
||||
expect(commands[0].content).toBe(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,9 +37,6 @@ export const createWebSocketTestSetup = (
|
||||
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
createWebSocketTestSetup("ws://localhost/events/socket");
|
||||
|
||||
@@ -10,13 +10,11 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
* Test component to access and display WebSocket connection state
|
||||
*/
|
||||
export function ConnectionStatusComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
const { connectionState } = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
<div data-testid="connection-state">{connectionState}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,22 +13,6 @@ vi.mock("#/context/ws-client-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useActiveConversation
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: {
|
||||
id: "test-conversation-id",
|
||||
conversation_version: "V0",
|
||||
},
|
||||
isFetched: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useConversationWebSocket (returns null for V0 conversations)
|
||||
vi.mock("#/contexts/conversation-websocket-context", () => ({
|
||||
useConversationWebSocket: () => null,
|
||||
}));
|
||||
|
||||
function TestTerminalComponent() {
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
|
||||
describe("useWebSocket", () => {
|
||||
describe.skip("useWebSocket", () => {
|
||||
// MSW WebSocket mock setup
|
||||
const wsLink = ws.link("ws://acme.com/ws");
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
|
||||
test("InteractiveChatBox should not have hardcoded English strings", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={() => {}} />
|
||||
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -24,5 +24,4 @@ test("mapProvider", () => {
|
||||
expect(mapProvider("replicate")).toBe("Replicate");
|
||||
expect(mapProvider("voyage")).toBe("Voyage AI");
|
||||
expect(mapProvider("openrouter")).toBe("OpenRouter");
|
||||
expect(mapProvider("clarifai")).toBe("Clarifai");
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CreateMicroagent,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "../open-hands.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
@@ -158,6 +159,19 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the web hosts
|
||||
* @returns Array of web hosts
|
||||
@@ -365,6 +379,22 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
V1AppConversationStartRequest,
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/vscode/url")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param message The message to send
|
||||
* @returns The sent message response
|
||||
*/
|
||||
static async sendMessage(
|
||||
conversationId: string,
|
||||
message: V1SendMessageRequest,
|
||||
): Promise<V1SendMessageResponse> {
|
||||
const { data } = await openHands.post<V1SendMessageResponse>(
|
||||
`/api/conversations/${conversationId}/events`,
|
||||
message,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new V1 conversation using the app-conversations API
|
||||
* Returns the start task immediately with app_conversation_id as null.
|
||||
* You must poll getStartTask() until status is READY to get the conversation ID.
|
||||
*
|
||||
* @returns AppConversationStartTask with task ID
|
||||
*/
|
||||
static async createConversation(
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
trigger?: ConversationTrigger,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
if (initialUserMsg) {
|
||||
body.initial_message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: initialUserMsg,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await openHands.post<V1AppConversationStartTask>(
|
||||
"/api/v1/app-conversations",
|
||||
body,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a start task by ID
|
||||
* Poll this endpoint until status is READY to get the app_conversation_id
|
||||
*
|
||||
* @param taskId The task UUID
|
||||
* @returns AppConversationStartTask or null
|
||||
*/
|
||||
static async getStartTask(
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
|
||||
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
|
||||
);
|
||||
|
||||
return data[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for start tasks (ongoing tasks that haven't completed yet)
|
||||
* Use this to find tasks that were started but the user navigated away
|
||||
*
|
||||
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
|
||||
* filter the results client-side after fetching.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Array of start tasks
|
||||
*/
|
||||
static async searchStartTasks(
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversationStartTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
||||
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
|
||||
*
|
||||
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns VSCode URL response
|
||||
*/
|
||||
static async getVSCodeUrl(
|
||||
_conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
const { data } = await axios.get<{ url: string | null }>(url, { headers });
|
||||
return {
|
||||
vscode_url: data.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseConversation(
|
||||
conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
*
|
||||
* @param ids Array of conversation IDs (max 100)
|
||||
* @returns Array of conversations or null for missing ones
|
||||
*/
|
||||
static async batchGetAppConversations(
|
||||
ids: string[],
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 conversations at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("ids", id));
|
||||
|
||||
const { data } = await openHands.get<(V1AppConversation | null)[]>(
|
||||
`/api/v1/app-conversations?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
// V1 API Types for requests
|
||||
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
|
||||
export interface V1MessageContent {
|
||||
type: "text" | "image_url";
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type V1Role = "user" | "system" | "assistant" | "tool";
|
||||
|
||||
export interface V1SendMessageRequest {
|
||||
role: V1Role;
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartRequest {
|
||||
sandbox_id?: string | null;
|
||||
initial_message?: V1SendMessageRequest | null;
|
||||
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
|
||||
llm_model?: string | null;
|
||||
selected_repository?: string | null;
|
||||
selected_branch?: string | null;
|
||||
git_provider?: Provider | null;
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
}
|
||||
|
||||
export type V1AppConversationStartTaskStatus =
|
||||
| "WORKING"
|
||||
| "WAITING_FOR_SANDBOX"
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
| "STARTING_CONVERSATION"
|
||||
| "READY"
|
||||
| "ERROR";
|
||||
|
||||
export interface V1AppConversationStartTask {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
status: V1AppConversationStartTaskStatus;
|
||||
detail: string | null;
|
||||
app_conversation_id: string | null;
|
||||
sandbox_id: string | null;
|
||||
agent_server_url: string | null;
|
||||
request: V1AppConversationStartRequest;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface V1SendMessageResponse {
|
||||
role: "user" | "system" | "assistant" | "tool";
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartTaskPage {
|
||||
items: V1AppConversationStartTask[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export type V1AgentExecutionStatus =
|
||||
| "RUNNING"
|
||||
| "AWAITING_USER_INPUT"
|
||||
| "AWAITING_USER_CONFIRMATION"
|
||||
| "FINISHED"
|
||||
| "PAUSED"
|
||||
| "STOPPED";
|
||||
|
||||
export interface V1AppConversation {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_id: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: Provider | null;
|
||||
title: string | null;
|
||||
trigger: ConversationTrigger | null;
|
||||
pr_number: number[];
|
||||
llm_model: string | null;
|
||||
metrics: unknown | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sandbox_status: V1SandboxStatus;
|
||||
agent_status: V1AgentExecutionStatus | null;
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
@@ -76,7 +76,6 @@ export interface Conversation {
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages as V0Messages } from "./messages";
|
||||
import { Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -30,18 +30,12 @@ import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import { isV0Event, isV1Event } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -54,10 +48,8 @@ function getEntryPoint(
|
||||
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { send } = useSendMessage();
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
@@ -73,7 +65,7 @@ export function ChatInterface() {
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
@@ -85,20 +77,11 @@ export function ChatInterface() {
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
const events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
@@ -110,8 +93,7 @@ export function ChatInterface() {
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
storeEvents.filter(isV1Event).some((event) => event.source === "agent"),
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
@@ -123,7 +105,7 @@ export function ChatInterface() {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (totalEvents === 0) {
|
||||
if (events.length === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
selectedRepository !== null,
|
||||
@@ -134,7 +116,7 @@ export function ChatInterface() {
|
||||
});
|
||||
} else {
|
||||
posthog.capture("user_message_sent", {
|
||||
session_message_count: totalEvents,
|
||||
session_message_count: events.length,
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
@@ -169,6 +151,11 @@ export function ChatInterface() {
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const onClickShareFeedbackActionButton = async (
|
||||
polarity: "positive" | "negative",
|
||||
) => {
|
||||
@@ -187,9 +174,7 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
const userEventsExist = hasUserEvent(events);
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
@@ -208,24 +193,15 @@ export function ChatInterface() {
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && !isV1Conversation && (
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
{!isLoadingMessages && userEventsExist && (
|
||||
<Messages
|
||||
messages={events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
@@ -237,7 +213,7 @@ export function ChatInterface() {
|
||||
<div className="flex justify-between relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<ConfirmationModeEnabled />
|
||||
{totalEvents > 0 && (
|
||||
{events.length > 0 && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
@@ -259,7 +235,10 @@ export function ChatInterface() {
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
|
||||
@@ -2,73 +2,33 @@ 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";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Empty function for now
|
||||
return;
|
||||
}
|
||||
|
||||
// V0: Send agent state change event to stop the agent
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing = pauseConversationSandboxMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={handlePauseAgent}
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
isPausing={isPausing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ChatInputContainerProps {
|
||||
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;
|
||||
@@ -24,6 +25,7 @@ interface ChatInputContainerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
@@ -36,6 +38,7 @@ export function ChatInputContainer({
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
@@ -45,6 +48,7 @@ export function ChatInputContainer({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onStop,
|
||||
}: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -76,7 +80,9 @@ export function ChatInputContainer({
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CustomChatInputProps {
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
onStop?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
@@ -27,6 +28,7 @@ export function CustomChatInput({
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
@@ -86,7 +88,7 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
);
|
||||
|
||||
const { handleSubmit, handleResumeAgent } = useChatSubmission(
|
||||
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
@@ -141,6 +143,7 @@ export function CustomChatInput({
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -150,6 +153,7 @@ export function CustomChatInput({
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,18 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
export function InteractiveChatBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const {
|
||||
images,
|
||||
files,
|
||||
@@ -25,7 +29,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
addImageLoading,
|
||||
removeImageLoading,
|
||||
} = useConversationStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// Helper function to validate and filter files
|
||||
@@ -141,6 +145,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
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";
|
||||
import { ChatStopButton } from "../chat/chat-stop-button";
|
||||
@@ -11,15 +12,13 @@ import { cn } from "#/utils/utils";
|
||||
import { AgentLoading } from "./agent-loading";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
export interface AgentStatusProps {
|
||||
className?: string;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
disabled?: boolean;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStatus({
|
||||
@@ -27,13 +26,12 @@ export function AgentStatus({
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
disabled = false,
|
||||
isPausing = false,
|
||||
}: AgentStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setShouldShownAgentLoading } = useConversationStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { webSocketStatus } = useWsClient();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const statusCode = getStatusCode(
|
||||
@@ -45,7 +43,6 @@ export function AgentStatus({
|
||||
);
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
webSocketStatus === "CONNECTING";
|
||||
|
||||
@@ -5,29 +5,31 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { t } = useTranslation();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Mutation hooks
|
||||
const stopConversationMutation = useStopConversation();
|
||||
const startConversationMutation = useStartConversation();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@@ -36,19 +38,6 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
@@ -63,31 +52,6 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return t(I18nKey.COMMON$STOPPING);
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return (
|
||||
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
|
||||
);
|
||||
}
|
||||
if (taskStatus === "READY") {
|
||||
return t(I18nKey.CONVERSATION$READY);
|
||||
}
|
||||
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
return (
|
||||
taskDetail ||
|
||||
taskStatus
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
@@ -112,13 +76,16 @@ export function ServerStatus({
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleStop();
|
||||
stopConversationMutation.mutate({ conversationId });
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleResumeAgent();
|
||||
startConversationMutation.mutate({
|
||||
conversationId,
|
||||
providers,
|
||||
});
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
|
||||
+1
-6
@@ -27,8 +27,6 @@ export function ConversationCardActions({
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
@@ -39,10 +37,7 @@ export function ConversationCardActions({
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5"
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
|
||||
+1
-11
@@ -5,32 +5,22 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
conversationStatus,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center mt-1",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
|
||||
-11
@@ -2,14 +2,12 @@ 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";
|
||||
import { ConversationVersionBadge } from "./conversation-version-badge";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationVersion?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
@@ -17,10 +15,7 @@ export function ConversationCardHeader({
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
}: ConversationCardHeaderProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
@@ -31,16 +26,10 @@ export function ConversationCardHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Version Badge */}
|
||||
<ConversationVersionBadge
|
||||
version={conversationVersion}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
|
||||
+1
-8
@@ -1,19 +1,15 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export type ConversationCardTitleMode = "view" | "edit";
|
||||
|
||||
export type ConversationCardTitleProps = {
|
||||
titleMode: ConversationCardTitleMode;
|
||||
title: string;
|
||||
onSave: (title: string) => void;
|
||||
isConversationArchived?: boolean;
|
||||
};
|
||||
|
||||
export function ConversationCardTitle({
|
||||
titleMode,
|
||||
title,
|
||||
onSave,
|
||||
isConversationArchived,
|
||||
}: ConversationCardTitleProps) {
|
||||
if (titleMode === "edit") {
|
||||
return (
|
||||
@@ -44,10 +40,7 @@ export function ConversationCardTitle({
|
||||
return (
|
||||
<p
|
||||
data-testid="conversation-card-title"
|
||||
className={cn(
|
||||
"text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
className="text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
|
||||
+1
-4
@@ -21,7 +21,6 @@ interface ConversationCardProps {
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
conversationVersion?: "V0" | "V1";
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
@@ -40,7 +39,6 @@ export function ConversationCard({
|
||||
createdAt,
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
@@ -110,6 +108,7 @@ export function ConversationCard({
|
||||
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">
|
||||
@@ -118,7 +117,6 @@ export function ConversationCard({
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationVersion={conversationVersion}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
@@ -140,7 +138,6 @@ export function ConversationCard({
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export function ConversationStatusBadges({
|
||||
|
||||
if (conversationStatus === "ARCHIVED") {
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full">
|
||||
<FaArchive size={10} className="text-white" />
|
||||
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
|
||||
</span>
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConversationVersionBadgeProps {
|
||||
version?: "V0" | "V1";
|
||||
isConversationArchived?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationVersionBadge({
|
||||
version,
|
||||
isConversationArchived,
|
||||
}: ConversationVersionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!version) return null;
|
||||
|
||||
const tooltipText =
|
||||
version === "V1"
|
||||
? t(I18nKey.CONVERSATION$VERSION_V1_NEW)
|
||||
: t(I18nKey.CONVERSATION$VERSION_V0_LEGACY);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipText} placement="top">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
|
||||
version === "V1"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-neutral-500/20 text-neutral-400",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useStartTasks } from "#/hooks/query/use-start-tasks";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -16,7 +15,6 @@ import { Provider } from "#/types/settings";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { ConversationCard } from "./conversation-card/conversation-card";
|
||||
import { StartTaskCard } from "./start-task-card/start-task-card";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -39,8 +37,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedConversationVersion, setSelectedConversationVersion] =
|
||||
React.useState<"V0" | "V1" | undefined>(undefined);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -54,15 +50,11 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Fetch in-progress start tasks
|
||||
const { data: startTasks } = useStartTasks();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
@@ -78,13 +70,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleStopConversation = (
|
||||
conversationId: string,
|
||||
version?: "V0" | "V1",
|
||||
) => {
|
||||
const handleStopConversation = (conversationId: string) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
setSelectedConversationVersion(version);
|
||||
};
|
||||
|
||||
const handleConversationTitleChange = async (
|
||||
@@ -118,10 +106,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
pauseConversationSandbox({
|
||||
conversationId: selectedConversationId,
|
||||
version: selectedConversationVersion,
|
||||
});
|
||||
stopConversation({ conversationId: selectedConversationId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,24 +131,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && conversations?.length === 0 && !startTasks?.length && (
|
||||
{!isFetching && conversations?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Render in-progress start tasks first */}
|
||||
{startTasks?.map((task) => (
|
||||
<NavLink
|
||||
key={task.id}
|
||||
to={`/conversations/task-${task.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<StartTaskCard task={task} />
|
||||
</NavLink>
|
||||
))}
|
||||
{/* Then render completed conversations */}
|
||||
{conversations?.map((project) => (
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
@@ -172,12 +146,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
>
|
||||
<ConversationCard
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onStop={() =>
|
||||
handleStopConversation(
|
||||
project.conversation_id,
|
||||
project.conversation_version,
|
||||
)
|
||||
}
|
||||
onStop={() => handleStopConversation(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleConversationTitleChange(project.conversation_id, title)
|
||||
}
|
||||
@@ -191,7 +160,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
conversationVersion={project.conversation_version}
|
||||
contextMenuOpen={openContextMenuId === project.conversation_id}
|
||||
onContextMenuToggle={(isOpen) =>
|
||||
setOpenContextMenuId(isOpen ? project.conversation_id : null)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MicroagentsModalHeader } from "./microagents-modal-header";
|
||||
import { MicroagentsLoadingState } from "./microagents-loading-state";
|
||||
import { MicroagentsEmptyState } from "./microagents-empty-state";
|
||||
import { MicroagentItem } from "./microagent-item";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -18,7 +18,7 @@ interface MicroagentsModalProps {
|
||||
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationRepoLink } from "../conversation-card/conversation-repo-link";
|
||||
import { NoRepository } from "../conversation-card/no-repository";
|
||||
import type { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface StartTaskCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
createdAt: string; // ISO 8601
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export function StartTaskCardFooter({
|
||||
selectedRepository,
|
||||
createdAt,
|
||||
detail,
|
||||
}: StartTaskCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1 mt-1")}>
|
||||
{/* Repository Info */}
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{selectedRepository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail */}
|
||||
{detail && (
|
||||
<div className="text-xs text-neutral-500 truncate">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { ConversationVersionBadge } from "../conversation-card/conversation-version-badge";
|
||||
import { StartTaskStatusIndicator } from "./start-task-status-indicator";
|
||||
import { StartTaskStatusBadge } from "./start-task-status-badge";
|
||||
|
||||
interface StartTaskCardHeaderProps {
|
||||
title: string;
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskCardHeader({
|
||||
title,
|
||||
taskStatus,
|
||||
}: StartTaskCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center">
|
||||
<StartTaskStatusIndicator taskStatus={taskStatus} />
|
||||
</div>
|
||||
|
||||
{/* Version Badge - V1 tasks are always V1 */}
|
||||
<ConversationVersionBadge version="V1" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium text-neutral-100 truncate flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Status Badge */}
|
||||
<StartTaskStatusBadge taskStatus={taskStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { V1AppConversationStartTask } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StartTaskCardHeader } from "./start-task-card-header";
|
||||
import { StartTaskCardFooter } from "./start-task-card-footer";
|
||||
|
||||
interface StartTaskCardProps {
|
||||
task: V1AppConversationStartTask;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StartTaskCard({ task, onClick }: StartTaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const title =
|
||||
task.request.title ||
|
||||
task.detail ||
|
||||
t(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
|
||||
const selectedRepository = task.request.selected_repository
|
||||
? {
|
||||
selected_repository: task.request.selected_repository,
|
||||
selected_branch: task.request.selected_branch || null,
|
||||
git_provider: task.request.git_provider || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="start-task-card"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"hover:bg-[#454545]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<StartTaskCardHeader title={title} taskStatus={task.status} />
|
||||
</div>
|
||||
|
||||
<StartTaskCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
createdAt={task.created_at}
|
||||
detail={task.detail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusBadgeProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusBadge({
|
||||
taskStatus,
|
||||
}: StartTaskStatusBadgeProps) {
|
||||
// Don't show badge for WORKING status (most common, clutters UI)
|
||||
if (taskStatus === "WORKING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format status for display
|
||||
const formatStatus = (status: string) =>
|
||||
status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
// Get status color
|
||||
const getStatusStyle = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500/10 text-green-400 border-green-500/20";
|
||||
case "ERROR":
|
||||
return "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
default:
|
||||
return "bg-yellow-500/10 text-yellow-400 border-yellow-500/20";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0",
|
||||
getStatusStyle(),
|
||||
)}
|
||||
>
|
||||
{formatStatus(taskStatus)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusIndicatorProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusIndicator({
|
||||
taskStatus,
|
||||
}: StartTaskStatusIndicatorProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500";
|
||||
case "ERROR":
|
||||
return "bg-red-500";
|
||||
case "WORKING":
|
||||
case "WAITING_FOR_SANDBOX":
|
||||
case "PREPARING_REPOSITORY":
|
||||
case "RUNNING_SETUP_SCRIPT":
|
||||
case "SETTING_UP_GIT_HOOKS":
|
||||
case "STARTING_CONVERSATION":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor())}
|
||||
aria-label={`Task status: ${taskStatus}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { MicroagentsModal } from "../conversation-panel/microagents-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
|
||||
|
||||
export function ConversationName() {
|
||||
const { t } = useTranslation();
|
||||
@@ -149,12 +148,6 @@ export function ConversationName() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<ConversationVersionBadge
|
||||
version={conversation.conversation_version}
|
||||
/>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<div className="relative flex items-center">
|
||||
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
export function VSCodeTooltipContent() {
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
@@ -15,7 +15,7 @@ interface JupyterEditorProps {
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
@@ -23,7 +23,7 @@ export function SetupPaymentModal() {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.BILLING$YOUVE_GOT_50)}
|
||||
|
||||
@@ -3,10 +3,10 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
function Terminal() {
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
@@ -98,7 +98,7 @@ export function AuthModal({
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
|
||||
export function ReauthModal() {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,7 +11,7 @@ export function ReauthModal() {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$LOGGING_BACK_IN)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { ActionSecurityRisk } from "#/stores/security-analyzer-store";
|
||||
@@ -11,7 +12,6 @@ import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
|
||||
export function ConfirmationButtons() {
|
||||
const submittedEventIds = useEventMessageStore(
|
||||
@@ -23,7 +23,7 @@ export function ConfirmationButtons() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { send } = useSendMessage();
|
||||
const { send } = useWsClient();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent action awaiting confirmation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -12,7 +12,7 @@ export function OpenHandsLogoButton() {
|
||||
ariaLabel={t(I18nKey.BRANDING$OPENHANDS_LOGO)}
|
||||
navLinkTo="/"
|
||||
>
|
||||
<OpenHandsLogo width={46} height={30} />
|
||||
<AllHandsLogo width={46} height={30} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
||||
import i18n from "#/i18n";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
import {
|
||||
ExecuteBashAction,
|
||||
FileEditorAction,
|
||||
StrReplaceEditorAction,
|
||||
MCPToolAction,
|
||||
ThinkAction,
|
||||
FinishAction,
|
||||
TaskTrackerAction,
|
||||
BrowserNavigateAction,
|
||||
BrowserClickAction,
|
||||
BrowserTypeAction,
|
||||
BrowserGetStateAction,
|
||||
BrowserGetContentAction,
|
||||
BrowserScrollAction,
|
||||
BrowserGoBackAction,
|
||||
BrowserListTabsAction,
|
||||
BrowserSwitchTabAction,
|
||||
BrowserCloseTabAction,
|
||||
} from "#/types/v1/core/base/action";
|
||||
|
||||
const getRiskText = (risk: SecurityRisk) => {
|
||||
switch (risk) {
|
||||
case SecurityRisk.LOW:
|
||||
return i18n.t("SECURITY$LOW_RISK");
|
||||
case SecurityRisk.MEDIUM:
|
||||
return i18n.t("SECURITY$MEDIUM_RISK");
|
||||
case SecurityRisk.HIGH:
|
||||
return i18n.t("SECURITY$HIGH_RISK");
|
||||
case SecurityRisk.UNKNOWN:
|
||||
default:
|
||||
return i18n.t("SECURITY$UNKNOWN_RISK");
|
||||
}
|
||||
};
|
||||
|
||||
const getNoContentActionContent = (): string => "";
|
||||
|
||||
// File Editor Actions
|
||||
const getFileEditorActionContent = (
|
||||
action: FileEditorAction | StrReplaceEditorAction,
|
||||
): string => {
|
||||
// Early return if not a create command or no file text
|
||||
if (action.command !== "create" || !action.file_text) {
|
||||
return getNoContentActionContent();
|
||||
}
|
||||
|
||||
// Process file text with length truncation
|
||||
let fileText = action.file_text;
|
||||
if (fileText.length > MAX_CONTENT_LENGTH) {
|
||||
fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return `${action.path}\n${fileText}`;
|
||||
};
|
||||
|
||||
// Command Actions
|
||||
const getExecuteBashActionContent = (
|
||||
event: ActionEvent<ExecuteBashAction>,
|
||||
): string => {
|
||||
let content = `Command:\n\`${event.action.command}\``;
|
||||
|
||||
// Add security risk information if it's HIGH or MEDIUM
|
||||
if (
|
||||
event.security_risk === SecurityRisk.HIGH ||
|
||||
event.security_risk === SecurityRisk.MEDIUM
|
||||
) {
|
||||
content += `\n\n${getRiskText(event.security_risk)}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Tool Actions
|
||||
const getMCPToolActionContent = (action: MCPToolAction): string => {
|
||||
// For V1, the tool name is in the event's tool_name property, not in the action
|
||||
let details = `**MCP Tool Call**\n\n`;
|
||||
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``;
|
||||
return details;
|
||||
};
|
||||
|
||||
// Simple Actions
|
||||
const getThinkActionContent = (action: ThinkAction): string => action.thought;
|
||||
|
||||
const getFinishActionContent = (action: FinishAction): string =>
|
||||
action.message.trim();
|
||||
|
||||
// Complex Actions
|
||||
const getTaskTrackerActionContent = (action: TaskTrackerAction): string => {
|
||||
let content = `**Command:** \`${action.command}\``;
|
||||
|
||||
// Handle plan command with task list
|
||||
if (action.command === "plan") {
|
||||
if (action.task_list && action.task_list.length > 0) {
|
||||
content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`;
|
||||
action.task_list.forEach((task, index: number) => {
|
||||
const statusMap = {
|
||||
todo: "⏳",
|
||||
in_progress: "🔄",
|
||||
done: "✅",
|
||||
};
|
||||
const statusIcon =
|
||||
statusMap[task.status as keyof typeof statusMap] || "❓";
|
||||
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
||||
if (task.notes) {
|
||||
content += `\n *Notes: ${task.notes}*`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
content += "\n\n**Task List:** Empty";
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Browser Actions
|
||||
type BrowserAction =
|
||||
| BrowserNavigateAction
|
||||
| BrowserClickAction
|
||||
| BrowserTypeAction
|
||||
| BrowserGetStateAction
|
||||
| BrowserGetContentAction
|
||||
| BrowserScrollAction
|
||||
| BrowserGoBackAction
|
||||
| BrowserListTabsAction
|
||||
| BrowserSwitchTabAction
|
||||
| BrowserCloseTabAction;
|
||||
|
||||
const getBrowserActionContent = (action: BrowserAction): string => {
|
||||
switch (action.kind) {
|
||||
case "BrowserNavigateAction":
|
||||
if ("url" in action) {
|
||||
return `Browsing ${action.url}`;
|
||||
}
|
||||
break;
|
||||
case "BrowserClickAction":
|
||||
case "BrowserTypeAction":
|
||||
case "BrowserGetStateAction":
|
||||
case "BrowserGetContentAction":
|
||||
case "BrowserScrollAction":
|
||||
case "BrowserGoBackAction":
|
||||
case "BrowserListTabsAction":
|
||||
case "BrowserSwitchTabAction":
|
||||
case "BrowserCloseTabAction":
|
||||
// These browser actions typically don't need detailed content display
|
||||
return getNoContentActionContent();
|
||||
default:
|
||||
return getNoContentActionContent();
|
||||
}
|
||||
|
||||
return getNoContentActionContent();
|
||||
};
|
||||
|
||||
export const getActionContent = (event: ActionEvent): string => {
|
||||
const { action } = event;
|
||||
const actionType = action.kind;
|
||||
|
||||
switch (actionType) {
|
||||
case "FileEditorAction":
|
||||
case "StrReplaceEditorAction":
|
||||
return getFileEditorActionContent(action);
|
||||
|
||||
case "ExecuteBashAction":
|
||||
return getExecuteBashActionContent(
|
||||
event as ActionEvent<ExecuteBashAction>,
|
||||
);
|
||||
|
||||
case "MCPToolAction":
|
||||
return getMCPToolActionContent(action);
|
||||
|
||||
case "ThinkAction":
|
||||
return getThinkActionContent(action);
|
||||
|
||||
case "FinishAction":
|
||||
return getFinishActionContent(action);
|
||||
|
||||
case "TaskTrackerAction":
|
||||
return getTaskTrackerActionContent(action);
|
||||
|
||||
case "BrowserNavigateAction":
|
||||
case "BrowserClickAction":
|
||||
case "BrowserTypeAction":
|
||||
case "BrowserGetStateAction":
|
||||
case "BrowserGetContentAction":
|
||||
case "BrowserScrollAction":
|
||||
case "BrowserGoBackAction":
|
||||
case "BrowserListTabsAction":
|
||||
case "BrowserSwitchTabAction":
|
||||
case "BrowserCloseTabAction":
|
||||
return getBrowserActionContent(action);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { MonoComponent } from "../../../features/chat/mono-component";
|
||||
import { PathComponent } from "../../../features/chat/path-component";
|
||||
import { getActionContent } from "./get-action-content";
|
||||
import { getObservationContent } from "./get-observation-content";
|
||||
import i18n from "#/i18n";
|
||||
|
||||
const trimText = (text: string, maxLength: number): string => {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
};
|
||||
|
||||
// Helper function to create title from translation key
|
||||
const createTitleFromKey = (
|
||||
key: string,
|
||||
values: Record<string, unknown>,
|
||||
): React.ReactNode => {
|
||||
if (!i18n.exists(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={key}
|
||||
values={values}
|
||||
components={{
|
||||
path: <PathComponent />,
|
||||
cmd: <MonoComponent />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Action Event Processing
|
||||
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an action event
|
||||
if (!isActionEvent(event)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const actionType = event.action.kind;
|
||||
let actionKey = "";
|
||||
let actionValues: Record<string, unknown> = {};
|
||||
|
||||
switch (actionType) {
|
||||
case "ExecuteBashAction":
|
||||
actionKey = "ACTION_MESSAGE$RUN";
|
||||
actionValues = {
|
||||
command: trimText(event.action.command, 80),
|
||||
};
|
||||
break;
|
||||
case "FileEditorAction":
|
||||
case "StrReplaceEditorAction":
|
||||
if (event.action.command === "view") {
|
||||
actionKey = "ACTION_MESSAGE$READ";
|
||||
} else if (event.action.command === "create") {
|
||||
actionKey = "ACTION_MESSAGE$WRITE";
|
||||
} else {
|
||||
actionKey = "ACTION_MESSAGE$EDIT";
|
||||
}
|
||||
actionValues = {
|
||||
path: event.action.path,
|
||||
};
|
||||
break;
|
||||
case "MCPToolAction":
|
||||
actionKey = "ACTION_MESSAGE$CALL_TOOL_MCP";
|
||||
actionValues = {
|
||||
mcp_tool_name: event.tool_name,
|
||||
};
|
||||
break;
|
||||
case "ThinkAction":
|
||||
actionKey = "ACTION_MESSAGE$THINK";
|
||||
break;
|
||||
case "FinishAction":
|
||||
actionKey = "ACTION_MESSAGE$FINISH";
|
||||
break;
|
||||
case "TaskTrackerAction":
|
||||
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
case "BrowserNavigateAction":
|
||||
actionKey = "ACTION_MESSAGE$BROWSE";
|
||||
break;
|
||||
default:
|
||||
// For unknown actions, use the type name
|
||||
return actionType.replace("Action", "").toUpperCase();
|
||||
}
|
||||
|
||||
if (actionKey) {
|
||||
return createTitleFromKey(actionKey, actionValues);
|
||||
}
|
||||
|
||||
return actionType;
|
||||
};
|
||||
|
||||
// Observation Event Processing
|
||||
const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an observation event
|
||||
if (!isObservationEvent(event)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const observationType = event.observation.kind;
|
||||
let observationKey = "";
|
||||
let observationValues: Record<string, unknown> = {};
|
||||
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$RUN";
|
||||
observationValues = {
|
||||
command: event.observation.command
|
||||
? trimText(event.observation.command, 80)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "FileEditorObservation":
|
||||
case "StrReplaceEditorObservation":
|
||||
if (event.observation.command === "view") {
|
||||
observationKey = "OBSERVATION_MESSAGE$READ";
|
||||
} else {
|
||||
observationKey = "OBSERVATION_MESSAGE$EDIT";
|
||||
}
|
||||
observationValues = {
|
||||
path: event.observation.path || "",
|
||||
};
|
||||
break;
|
||||
case "MCPToolObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$MCP";
|
||||
observationValues = {
|
||||
mcp_tool_name: event.observation.tool_name,
|
||||
};
|
||||
break;
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
}
|
||||
|
||||
if (observationKey) {
|
||||
return createTitleFromKey(observationKey, observationValues);
|
||||
}
|
||||
|
||||
return observationType;
|
||||
};
|
||||
|
||||
export const getEventContent = (event: OpenHandsEvent) => {
|
||||
let title: React.ReactNode = "";
|
||||
let details: string = "";
|
||||
|
||||
if (isActionEvent(event)) {
|
||||
title = getActionEventTitle(event);
|
||||
details = getActionContent(event);
|
||||
} else if (isObservationEvent(event)) {
|
||||
title = getObservationEventTitle(event);
|
||||
details = getObservationContent(event);
|
||||
}
|
||||
|
||||
return {
|
||||
title: title || i18n.t("EVENT$UNKNOWN_EVENT"),
|
||||
details: details || i18n.t("EVENT$UNKNOWN_EVENT"),
|
||||
};
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { getObservationResult } from "./get-observation-result";
|
||||
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
||||
import i18n from "#/i18n";
|
||||
import {
|
||||
MCPToolObservation,
|
||||
FinishObservation,
|
||||
ThinkObservation,
|
||||
BrowserObservation,
|
||||
ExecuteBashObservation,
|
||||
FileEditorObservation,
|
||||
StrReplaceEditorObservation,
|
||||
TaskTrackerObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
// File Editor Observations
|
||||
const getFileEditorObservationContent = (
|
||||
event: ObservationEvent<FileEditorObservation | StrReplaceEditorObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
const successMessage = getObservationResult(event) === "success";
|
||||
|
||||
// For view commands or successful edits with content changes, format as code block
|
||||
if (
|
||||
(successMessage &&
|
||||
"old_content" in observation &&
|
||||
"new_content" in observation &&
|
||||
observation.old_content &&
|
||||
observation.new_content) ||
|
||||
observation.command === "view"
|
||||
) {
|
||||
return `\`\`\`\n${observation.output}\n\`\`\``;
|
||||
}
|
||||
|
||||
// For other commands, return the output as-is
|
||||
return observation.output;
|
||||
};
|
||||
|
||||
// Command Observations
|
||||
const getExecuteBashObservationContent = (
|
||||
event: ObservationEvent<ExecuteBashObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
let { output } = observation;
|
||||
|
||||
if (output.length > MAX_CONTENT_LENGTH) {
|
||||
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return `Output:\n\`\`\`sh\n${output.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
|
||||
};
|
||||
|
||||
// Tool Observations
|
||||
const getBrowserObservationContent = (
|
||||
event: ObservationEvent<BrowserObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
let contentDetails = "";
|
||||
|
||||
if ("error" in observation && observation.error) {
|
||||
contentDetails += `**Error:**\n${observation.error}\n\n`;
|
||||
}
|
||||
|
||||
contentDetails += `**Output:**\n${observation.output}`;
|
||||
|
||||
if (contentDetails.length > MAX_CONTENT_LENGTH) {
|
||||
contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return contentDetails;
|
||||
};
|
||||
|
||||
const getMCPToolObservationContent = (
|
||||
event: ObservationEvent<MCPToolObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Tool:** ${observation.tool_name}\n\n`;
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else {
|
||||
content += `**Result:**\n${textContent}`;
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Complex Observations
|
||||
const getTaskTrackerObservationContent = (
|
||||
event: ObservationEvent<TaskTrackerObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
const { command, task_list: taskList } = observation;
|
||||
let content = `**Command:** \`${command}\``;
|
||||
|
||||
if (command === "plan" && taskList.length > 0) {
|
||||
content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`;
|
||||
|
||||
taskList.forEach((task, index: number) => {
|
||||
const statusMap = {
|
||||
todo: "⏳",
|
||||
in_progress: "🔄",
|
||||
done: "✅",
|
||||
};
|
||||
const statusIcon =
|
||||
statusMap[task.status as keyof typeof statusMap] || "❓";
|
||||
|
||||
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
||||
if (task.notes) {
|
||||
content += `\n *Notes: ${task.notes}*`;
|
||||
}
|
||||
});
|
||||
} else if (command === "plan") {
|
||||
content += "\n\n**Task List:** Empty";
|
||||
}
|
||||
|
||||
if (
|
||||
"content" in observation &&
|
||||
observation.content &&
|
||||
observation.content.trim()
|
||||
) {
|
||||
content += `\n\n**Result:** ${observation.content.trim()}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Simple Observations
|
||||
const getThinkObservationContent = (
|
||||
event: ObservationEvent<ThinkObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
return observation.content || "";
|
||||
};
|
||||
|
||||
const getFinishObservationContent = (
|
||||
event: ObservationEvent<FinishObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
return observation.message || "";
|
||||
};
|
||||
|
||||
export const getObservationContent = (event: ObservationEvent): string => {
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
switch (observationType) {
|
||||
case "FileEditorObservation":
|
||||
case "StrReplaceEditorObservation":
|
||||
return getFileEditorObservationContent(
|
||||
event as ObservationEvent<
|
||||
FileEditorObservation | StrReplaceEditorObservation
|
||||
>,
|
||||
);
|
||||
|
||||
case "ExecuteBashObservation":
|
||||
return getExecuteBashObservationContent(
|
||||
event as ObservationEvent<ExecuteBashObservation>,
|
||||
);
|
||||
|
||||
case "BrowserObservation":
|
||||
return getBrowserObservationContent(
|
||||
event as ObservationEvent<BrowserObservation>,
|
||||
);
|
||||
|
||||
case "MCPToolObservation":
|
||||
return getMCPToolObservationContent(
|
||||
event as ObservationEvent<MCPToolObservation>,
|
||||
);
|
||||
|
||||
case "TaskTrackerObservation":
|
||||
return getTaskTrackerObservationContent(
|
||||
event as ObservationEvent<TaskTrackerObservation>,
|
||||
);
|
||||
|
||||
case "ThinkObservation":
|
||||
return getThinkObservationContent(
|
||||
event as ObservationEvent<ThinkObservation>,
|
||||
);
|
||||
|
||||
case "FinishObservation":
|
||||
return getFinishObservationContent(
|
||||
event as ObservationEvent<FinishObservation>,
|
||||
);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
|
||||
export type ObservationResultStatus = "success" | "error" | "timeout";
|
||||
|
||||
export const getObservationResult = (
|
||||
event: ObservationEvent,
|
||||
): ObservationResultStatus => {
|
||||
const { observation } = event;
|
||||
const observationType = observation.kind;
|
||||
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation": {
|
||||
const exitCode = observation.exit_code;
|
||||
|
||||
if (exitCode === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0) return "success"; // Command executed successfully
|
||||
return "error"; // Command failed
|
||||
}
|
||||
case "FileEditorObservation":
|
||||
case "StrReplaceEditorObservation":
|
||||
// Check if there's an error
|
||||
if (observation.error) return "error";
|
||||
return "success";
|
||||
case "MCPToolObservation":
|
||||
if (observation.is_error) return "error";
|
||||
return "success";
|
||||
default:
|
||||
return "success";
|
||||
}
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { MessageEvent } from "#/types/v1/core";
|
||||
import i18n from "#/i18n";
|
||||
|
||||
export const parseMessageFromEvent = (event: MessageEvent): string => {
|
||||
const message = event.llm_message;
|
||||
|
||||
// Safety check: ensure llm_message exists and has content
|
||||
if (!message || !message.content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Get the text content from the message
|
||||
let textContent = "";
|
||||
if (message.content) {
|
||||
if (Array.isArray(message.content)) {
|
||||
// Handle array of content blocks
|
||||
textContent = message.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("\n");
|
||||
} else if (typeof message.content === "string") {
|
||||
// Handle string content
|
||||
textContent = message.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are image_urls in the message content
|
||||
const hasImages =
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some((content) => content.type === "image");
|
||||
|
||||
if (!hasImages) {
|
||||
return textContent;
|
||||
}
|
||||
|
||||
// If there are images, try to split by the augmented prompt delimiter
|
||||
const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
|
||||
const parts = textContent.split(delimiter);
|
||||
|
||||
return parts[0];
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
|
||||
export const MAX_CONTENT_LENGTH = 1000;
|
||||
|
||||
export const getDefaultEventContent = (event: OpenHandsEvent): string =>
|
||||
`\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import {
|
||||
isActionEvent,
|
||||
isObservationEvent,
|
||||
isMessageEvent,
|
||||
isAgentErrorEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
// V1 events that should not be rendered
|
||||
const NO_RENDER_ACTION_TYPES = [
|
||||
"ThinkAction",
|
||||
// Add more action types that should not be rendered
|
||||
];
|
||||
|
||||
const NO_RENDER_OBSERVATION_TYPES = [
|
||||
"ThinkObservation",
|
||||
// Add more observation types that should not be rendered
|
||||
];
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
// Explicitly exclude system events that should not be rendered in chat
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Render action events (with filtering)
|
||||
if (isActionEvent(event)) {
|
||||
// For V1, action is an object with kind property
|
||||
const actionType = event.action.kind;
|
||||
|
||||
// Hide user commands from the chat interface
|
||||
if (actionType === "ExecuteBashAction" && event.source === "user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !NO_RENDER_ACTION_TYPES.includes(actionType);
|
||||
}
|
||||
|
||||
// Render observation events (with filtering)
|
||||
if (isObservationEvent(event)) {
|
||||
// For V1, observation is an object with kind property
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
// Note: ObservationEvent source is always "environment", not "user"
|
||||
// So no need to check for user source here
|
||||
|
||||
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
|
||||
}
|
||||
|
||||
// Render message events (user and assistant messages)
|
||||
if (isMessageEvent(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render agent error events
|
||||
if (isAgentErrorEvent(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't render any other event types (system events, etc.)
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasUserEvent = (events: OpenHandsEvent[]) =>
|
||||
events.some((event) => event.source === "user");
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react";
|
||||
import { AgentErrorEvent } from "#/types/v1/core";
|
||||
import { isAgentErrorEvent } from "#/types/v1/type-guards";
|
||||
import { ErrorMessage } from "../../../features/chat/error-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface ErrorEventMessageProps {
|
||||
event: AgentErrorEvent;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ErrorEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: ErrorEventMessageProps) {
|
||||
if (!isAgentErrorEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ErrorMessage
|
||||
// V1 doesn't have error_id, use event.id instead
|
||||
errorId={event.id}
|
||||
defaultMessage={event.error}
|
||||
/>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { FinishAction } from "#/types/v1/core/base/action";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface FinishEventMessageProps {
|
||||
event: ActionEvent<FinishAction>;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function FinishEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: FinishEventMessageProps) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={details}
|
||||
success={
|
||||
isObservationEvent(event) ? getObservationResult(event) : undefined
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { UserAssistantEventMessage } from "./user-assistant-event-message";
|
||||
export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { isActionEvent } from "#/types/v1/type-guards";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface ObservationPairEventMessageProps {
|
||||
event: ActionEvent;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ObservationPairEventMessage({
|
||||
event,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: ObservationPairEventMessageProps) {
|
||||
if (!isActionEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's thought content to display
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
if (thoughtContent && event.action.kind !== "ThinkAction") {
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import React from "react";
|
||||
import { MessageEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
// Extract image URLs from the message content
|
||||
const imageUrls: string[] = [];
|
||||
if (Array.isArray(event.llm_message.content)) {
|
||||
event.llm_message.content.forEach((content) => {
|
||||
if (content.type === "image") {
|
||||
imageUrls.push(...content.image_urls);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message} actions={actions}>
|
||||
{imageUrls.length > 0 && (
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
{/* LikertScaleWrapper expects V0 event types, skip for now */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent, MessageEvent, ActionEvent } from "#/types/v1/core";
|
||||
import { FinishAction } from "#/types/v1/core/base/action";
|
||||
import {
|
||||
isActionEvent,
|
||||
isObservationEvent,
|
||||
isAgentErrorEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
// TODO: Implement V1 feedback functionality when API supports V1 event IDs
|
||||
// import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
// For now, we'll skip feedback functionality for V1 events
|
||||
const feedbackData = { exists: false };
|
||||
const isCheckingFeedback = false;
|
||||
|
||||
// Common props for components that need them
|
||||
const commonProps = {
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
isInLast10Actions,
|
||||
config,
|
||||
isCheckingFeedback,
|
||||
feedbackData,
|
||||
};
|
||||
|
||||
// Agent error events
|
||||
if (isAgentErrorEvent(event)) {
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
<FinishEventMessage
|
||||
event={event as ActionEvent<FinishAction>}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { Messages } from "./messages";
|
||||
export { EventMessage } from "./event-message";
|
||||
export * from "./event-message-components";
|
||||
export { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
export {
|
||||
shouldRenderEvent,
|
||||
hasUserEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
// TODO: Implement microagent functionality for V1 when APIs support V1 event IDs
|
||||
// import { AgentState } from "#/types/agent-state";
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => (
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
// microagentStatus={undefined}
|
||||
// microagentConversationId={undefined}
|
||||
// microagentPRUrl={undefined}
|
||||
// actions={undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Prevent re-renders if messages are the same length
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
Messages.displayName = "Messages";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./chat";
|
||||
@@ -28,12 +28,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
/**
|
||||
* @deprecated Use `V1_WebSocketConnectionState` from `conversation-websocket-context.tsx` instead.
|
||||
* This type is for legacy V0 conversations only.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V0_WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
|
||||
|
||||
const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
|
||||
typeof obj === "object" &&
|
||||
@@ -74,7 +69,7 @@ const isMessageAction = (
|
||||
isUserMessage(event) || isAssistantMessage(event);
|
||||
|
||||
interface UseWsClient {
|
||||
webSocketStatus: V0_WebSocketStatus;
|
||||
webSocketStatus: WebSocketStatus;
|
||||
isLoadingMessages: boolean;
|
||||
send: (event: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -137,7 +132,7 @@ export function WsClientProvider({
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<V0_WebSocketStatus>("DISCONNECTED");
|
||||
React.useState<WebSocketStatus>("DISCONNECTED");
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
|
||||
@@ -7,37 +7,20 @@ import React, {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useCommandStore } from "#/state/command-store";
|
||||
import {
|
||||
isV1Event,
|
||||
isAgentErrorEvent,
|
||||
isUserMessageEvent,
|
||||
isActionEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
isFullStateConversationStateUpdateEvent,
|
||||
isAgentStatusConversationStateUpdateEvent,
|
||||
isExecuteBashActionEvent,
|
||||
isExecuteBashObservationEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
| "CONNECTING"
|
||||
| "OPEN"
|
||||
| "CLOSED"
|
||||
| "CLOSING";
|
||||
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
@@ -47,42 +30,22 @@ const ConversationWebSocketContext = createContext<
|
||||
export function ConversationWebSocketProvider({
|
||||
children,
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
conversationId?: string;
|
||||
conversationUrl?: string | null;
|
||||
sessionApiKey?: string | null;
|
||||
}) {
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<V1_WebSocketConnectionState>("CONNECTING");
|
||||
// Track if we've ever successfully connected
|
||||
// Don't show errors until after first successful connection
|
||||
const hasConnectedRef = React.useRef(false);
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
|
||||
>("CONNECTING");
|
||||
const queryClient = useQueryClient();
|
||||
const { addEvent } = useEventStore();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { setAgentStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
|
||||
// Build WebSocket URL from props
|
||||
const wsUrl = useMemo(
|
||||
() => buildWebSocketUrl(conversationId, conversationUrl),
|
||||
[conversationId, conversationUrl],
|
||||
);
|
||||
|
||||
// Reset hasConnected flag when conversation changes
|
||||
useEffect(() => {
|
||||
hasConnectedRef.current = false;
|
||||
}, [conversationId]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
@@ -107,68 +70,25 @@ export function ConversationWebSocketProvider({
|
||||
queryClient,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle conversation state updates
|
||||
// TODO: Tests
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
if (isFullStateConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value.agent_status);
|
||||
}
|
||||
if (isAgentStatusConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ExecuteBashAction events - add command as input to terminal
|
||||
if (isExecuteBashActionEvent(event)) {
|
||||
appendInput(event.action.command);
|
||||
}
|
||||
|
||||
// Handle ExecuteBashObservation events - add output to terminal
|
||||
if (isExecuteBashObservationEvent(event)) {
|
||||
appendOutput(event.observation.output);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse WebSocket message as JSON:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
setErrorMessage,
|
||||
removeOptimisticUserMessage,
|
||||
queryClient,
|
||||
conversationId,
|
||||
setAgentStatus,
|
||||
appendInput,
|
||||
appendOutput,
|
||||
],
|
||||
[addEvent, setErrorMessage, removeOptimisticUserMessage, queryClient],
|
||||
);
|
||||
|
||||
const websocketOptions: WebSocketHookOptions = useMemo(() => {
|
||||
const queryParams: Record<string, string | boolean> = {
|
||||
resend_all: true,
|
||||
};
|
||||
|
||||
// Add session_api_key if available
|
||||
if (sessionApiKey) {
|
||||
queryParams.session_api_key = sessionApiKey;
|
||||
}
|
||||
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
const websocketOptions = useMemo(
|
||||
() => ({
|
||||
onOpen: () => {
|
||||
setConnectionState("OPEN");
|
||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
// Only show error message if we've previously connected successfully
|
||||
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
|
||||
if (event.code !== 1000 && hasConnectedRef.current) {
|
||||
// Set error message for unexpected disconnects (not normal closure)
|
||||
if (event.code !== 1000) {
|
||||
setErrorMessage(
|
||||
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
|
||||
);
|
||||
@@ -176,44 +96,20 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionState("CLOSED");
|
||||
// Only show error message if we've previously connected successfully
|
||||
if (hasConnectedRef.current) {
|
||||
setErrorMessage("Failed to connect to server");
|
||||
}
|
||||
setErrorMessage("Failed to connect to server");
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
};
|
||||
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
|
||||
}),
|
||||
[handleMessage, setErrorMessage, removeErrorMessage],
|
||||
);
|
||||
|
||||
// Build a fallback URL to prevent hook from connecting if conversation data isn't ready
|
||||
const websocketUrl = wsUrl || "ws://localhost/placeholder";
|
||||
const { socket } = useWebSocket(websocketUrl, websocketOptions);
|
||||
|
||||
// V1 send message function via WebSocket
|
||||
const sendMessage = useCallback(
|
||||
async (message: V1SendMessageRequest) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
const error = "WebSocket is not connected";
|
||||
setErrorMessage(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send message through WebSocket as JSON
|
||||
socket.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to send message";
|
||||
setErrorMessage(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[socket, setErrorMessage],
|
||||
const { socket } = useWebSocket(
|
||||
"ws://localhost/events/socket",
|
||||
websocketOptions,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only process socket updates if we have a valid URL
|
||||
if (socket && wsUrl) {
|
||||
if (socket) {
|
||||
// Update state based on socket readyState
|
||||
const updateState = () => {
|
||||
switch (socket.readyState) {
|
||||
@@ -237,12 +133,9 @@ export function ConversationWebSocketProvider({
|
||||
|
||||
updateState();
|
||||
}
|
||||
}, [socket, wsUrl]);
|
||||
}, [socket]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ connectionState, sendMessage }),
|
||||
[connectionState, sendMessage],
|
||||
);
|
||||
const contextValue = useMemo(() => ({ connectionState }), [connectionState]);
|
||||
|
||||
return (
|
||||
<ConversationWebSocketContext.Provider value={contextValue}>
|
||||
@@ -252,9 +145,12 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
|
||||
export const useConversationWebSocket =
|
||||
(): ConversationWebSocketContextType | null => {
|
||||
(): ConversationWebSocketContextType => {
|
||||
const context = useContext(ConversationWebSocketContext);
|
||||
// Return null instead of throwing when not in provider
|
||||
// This allows the hook to be called conditionally based on conversation version
|
||||
return context || null;
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useConversationWebSocket must be used within a ConversationWebSocketProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface WebSocketProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -34,9 +33,6 @@ export function WebSocketProviderWrapper({
|
||||
conversationId,
|
||||
version,
|
||||
}: WebSocketProviderWrapperProps) {
|
||||
// Get conversation data for V1 provider
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
if (version === 0) {
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
@@ -47,11 +43,7 @@ export function WebSocketProviderWrapper({
|
||||
|
||||
if (version === 1) {
|
||||
return (
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversation?.url}
|
||||
sessionApiKey={conversation?.session_api_key}
|
||||
>
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { Provider } from "#/types/settings";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
/**
|
||||
* Gets the conversation version from the cache
|
||||
*/
|
||||
export const getConversationVersionFromQueryCache = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
): "V0" | "V1" => {
|
||||
const conversation = queryClient.getQueryData<{
|
||||
conversation_version?: string;
|
||||
}>(["user", "conversation", conversationId]);
|
||||
|
||||
return conversation?.conversation_version === "V1" ? "V1" : "V0";
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a V1 conversation's sandbox_id
|
||||
*/
|
||||
const fetchV1ConversationSandboxId = async (
|
||||
conversationId: string,
|
||||
): Promise<string> => {
|
||||
const conversations = await V1ConversationService.batchGetAppConversations([
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
const appConversation = conversations[0];
|
||||
if (!appConversation) {
|
||||
throw new Error(`V1 conversation not found: ${conversationId}`);
|
||||
}
|
||||
|
||||
return appConversation.sandbox_id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation sandbox by fetching the sandbox_id and pausing it
|
||||
*/
|
||||
export const pauseV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
return V1ConversationService.pauseSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops a V0 conversation using the legacy API
|
||||
*/
|
||||
export const stopV0Conversation = async (conversationId: string) =>
|
||||
ConversationService.stopConversation(conversationId);
|
||||
|
||||
/**
|
||||
* Resumes a V1 conversation sandbox by fetching the sandbox_id and resuming it
|
||||
*/
|
||||
export const resumeV1ConversationSandbox = async (conversationId: string) => {
|
||||
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
|
||||
return V1ConversationService.resumeSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a V0 conversation using the legacy API
|
||||
*/
|
||||
export const startV0Conversation = async (
|
||||
conversationId: string,
|
||||
providers?: Provider[],
|
||||
) => ConversationService.startConversation(conversationId, providers);
|
||||
|
||||
/**
|
||||
* Optimistically updates the conversation status in the cache
|
||||
*/
|
||||
export const updateConversationStatusInCache = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
status: string,
|
||||
): void => {
|
||||
// Update the individual conversation cache
|
||||
queryClient.setQueryData<{ status: string }>(
|
||||
["user", "conversation", conversationId],
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return { ...oldData, status };
|
||||
},
|
||||
);
|
||||
|
||||
// Update the conversations list cache
|
||||
queryClient.setQueriesData<{
|
||||
pages: Array<{
|
||||
results: Array<{ conversation_id: string; status: string }>;
|
||||
}>;
|
||||
}>({ queryKey: ["user", "conversations"] }, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
results: page.results.map((conv) =>
|
||||
conv.conversation_id === conversationId ? { ...conv, status } : conv,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidates all queries related to conversation mutations (start/stop)
|
||||
*/
|
||||
export const invalidateConversationQueries = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
): void => {
|
||||
// Invalidate the specific conversation query to trigger automatic refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
// Also invalidate the conversations list for consistency
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Invalidate V1 batch get queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["v1-batch-get-app-conversations"],
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
|
||||
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
|
||||
import { CreateMicroagent } from "#/api/open-hands.types";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
@@ -19,24 +17,12 @@ interface CreateConversationVariables {
|
||||
createMicroagent?: CreateMicroagent;
|
||||
}
|
||||
|
||||
// Response type that combines both V1 and legacy responses
|
||||
interface CreateConversationResponse extends Partial<Conversation> {
|
||||
conversation_id: string;
|
||||
session_api_key: string | null;
|
||||
url: string | null;
|
||||
// V1 specific fields
|
||||
v1_task_id?: string;
|
||||
is_v1?: boolean;
|
||||
}
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (
|
||||
variables: CreateConversationVariables,
|
||||
): Promise<CreateConversationResponse> => {
|
||||
mutationFn: async (variables: CreateConversationVariables) => {
|
||||
const {
|
||||
query,
|
||||
repository,
|
||||
@@ -45,33 +31,7 @@ export const useCreateConversation = () => {
|
||||
createMicroagent,
|
||||
} = variables;
|
||||
|
||||
const useV1 = USE_V1_CONVERSATION_API();
|
||||
|
||||
if (useV1) {
|
||||
// Use V1 API - creates a conversation start task
|
||||
const startTask = await V1ConversationService.createConversation(
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
undefined, // trigger - will be set by backend
|
||||
);
|
||||
|
||||
// Return a special task ID that the frontend will recognize
|
||||
// Format: "task-{uuid}" so the conversation screen can poll the task
|
||||
// Once the task is ready, it will navigate to the actual conversation ID
|
||||
return {
|
||||
conversation_id: `task-${startTask.id}`,
|
||||
session_api_key: null,
|
||||
url: startTask.agent_server_url,
|
||||
v1_task_id: startTask.id,
|
||||
is_v1: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Use legacy API
|
||||
const conversation = await ConversationService.createConversation(
|
||||
return ConversationService.createConversation(
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
@@ -80,11 +40,6 @@ export const useCreateConversation = () => {
|
||||
conversationInstructions,
|
||||
createMicroagent,
|
||||
);
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
is_v1: false,
|
||||
};
|
||||
},
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
getConversationVersionFromQueryCache,
|
||||
resumeV1ConversationSandbox,
|
||||
startV0Conversation,
|
||||
updateConversationStatusInCache,
|
||||
invalidateConversationQueries,
|
||||
} from "./conversation-mutation-utils";
|
||||
|
||||
/**
|
||||
* Unified hook that automatically routes to the correct resume conversation sandbox implementation
|
||||
* based on the conversation version (V0 or V1).
|
||||
*
|
||||
* This hook checks the cached conversation data to determine the version, then calls
|
||||
* the appropriate API directly. Returns a single useMutation instance that all components share.
|
||||
*
|
||||
* Usage is the same as useStartConversation:
|
||||
* const { mutate: startConversation } = useUnifiedResumeConversationSandbox();
|
||||
* startConversation({ conversationId: "some-id", providers: [...] });
|
||||
*/
|
||||
export const useUnifiedResumeConversationSandbox = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const removeErrorMessage = useErrorMessageStore(
|
||||
(state) => state.removeErrorMessage,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["start-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
conversationId: string;
|
||||
providers?: Provider[];
|
||||
version?: "V0" | "V1";
|
||||
}) => {
|
||||
// Use provided version or fallback to cache lookup
|
||||
const version =
|
||||
variables.version ||
|
||||
getConversationVersionFromQueryCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
);
|
||||
|
||||
if (version === "V1") {
|
||||
return resumeV1ConversationSandbox(variables.conversationId);
|
||||
}
|
||||
|
||||
return startV0Conversation(variables.conversationId, variables.providers);
|
||||
},
|
||||
onMutate: async () => {
|
||||
toast.loading(t(I18nKey.TOAST$STARTING_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
toast.dismiss();
|
||||
toast.error(t(I18nKey.TOAST$FAILED_TO_START_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
invalidateConversationQueries(queryClient, variables.conversationId);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.dismiss();
|
||||
toast.success(t(I18nKey.TOAST$CONVERSATION_STARTED), TOAST_OPTIONS);
|
||||
|
||||
// Clear error messages when starting/resuming conversation
|
||||
removeErrorMessage();
|
||||
|
||||
updateConversationStatusInCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
"RUNNING",
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
getConversationVersionFromQueryCache,
|
||||
pauseV1ConversationSandbox,
|
||||
stopV0Conversation,
|
||||
updateConversationStatusInCache,
|
||||
invalidateConversationQueries,
|
||||
} from "./conversation-mutation-utils";
|
||||
|
||||
/**
|
||||
* Unified hook that automatically routes to the correct pause conversation sandbox
|
||||
* implementation based on the conversation version (V0 or V1).
|
||||
*
|
||||
* This hook checks the cached conversation data to determine the version, then calls
|
||||
* the appropriate API directly. Returns a single useMutation instance that all components share.
|
||||
*
|
||||
* Usage is the same as useStopConversation:
|
||||
* const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
||||
* stopConversation({ conversationId: "some-id" });
|
||||
*/
|
||||
export const useUnifiedPauseConversationSandbox = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ conversationId: string }>();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["stop-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
conversationId: string;
|
||||
version?: "V0" | "V1";
|
||||
}) => {
|
||||
// Use provided version or fallback to cache lookup
|
||||
const version =
|
||||
variables.version ||
|
||||
getConversationVersionFromQueryCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
);
|
||||
|
||||
if (version === "V1") {
|
||||
return pauseV1ConversationSandbox(variables.conversationId);
|
||||
}
|
||||
|
||||
return stopV0Conversation(variables.conversationId);
|
||||
},
|
||||
onMutate: async () => {
|
||||
toast.loading(t(I18nKey.TOAST$STOPPING_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
toast.dismiss();
|
||||
toast.error(t(I18nKey.TOAST$FAILED_TO_STOP_CONVERSATION), TOAST_OPTIONS);
|
||||
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
invalidateConversationQueries(queryClient, variables.conversationId);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.dismiss();
|
||||
toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
|
||||
|
||||
updateConversationStatusInCache(
|
||||
queryClient,
|
||||
variables.conversationId,
|
||||
"STOPPED",
|
||||
);
|
||||
|
||||
// Only redirect if we're stopping the conversation we're currently viewing
|
||||
if (params.conversationId === variables.conversationId) {
|
||||
navigate("/");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -5,23 +5,14 @@ import ConversationService from "#/api/conversation-service/conversation-service
|
||||
|
||||
export const useActiveConversation = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Don't poll if this is a task ID (format: "task-{uuid}")
|
||||
// Task polling is handled by useTaskPolling hook
|
||||
const isTaskId = conversationId.startsWith("task-");
|
||||
const actualConversationId = isTaskId ? null : conversationId;
|
||||
|
||||
const userConversation = useUserConversation(
|
||||
actualConversationId,
|
||||
(query) => {
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
// TODO: Return conversation title as a WS event to avoid polling
|
||||
// This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
|
||||
return 30000; // 30 seconds
|
||||
},
|
||||
);
|
||||
const userConversation = useUserConversation(conversationId, (query) => {
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
// TODO: Return conversation title as a WS event to avoid polling
|
||||
// This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
|
||||
return 30000; // 30 seconds
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const conversation = userConversation.data;
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
export const useConversationMicroagents = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curAgentState } = useAgentStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "microagents"],
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
/**
|
||||
* Hook to fetch in-progress V1 conversation start tasks
|
||||
*
|
||||
* Use case: Show tasks that are provisioning sandboxes, cloning repos, etc.
|
||||
* These are conversations that started but haven't reached READY or ERROR status yet.
|
||||
*
|
||||
* Note: Filters out READY and ERROR status tasks client-side since backend doesn't support status filtering.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Query result with array of in-progress start tasks
|
||||
*/
|
||||
export const useStartTasks = (limit = 10) =>
|
||||
useQuery({
|
||||
queryKey: ["start-tasks", "search", limit],
|
||||
queryFn: () => V1ConversationService.searchStartTasks(limit),
|
||||
select: (tasks) =>
|
||||
tasks.filter(
|
||||
(task) => task.status !== "READY" && task.status !== "ERROR",
|
||||
),
|
||||
staleTime: 1000 * 60 * 1, // 1 minute (short since these are in-progress)
|
||||
gcTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Hook that polls V1 conversation start tasks and navigates when ready.
|
||||
*
|
||||
* This hook:
|
||||
* - Detects if the conversationId URL param is a task ID (format: "task-{uuid}")
|
||||
* - Polls the V1 start task API every 3 seconds until status is READY or ERROR
|
||||
* - Automatically navigates to the conversation URL when the task becomes READY
|
||||
* - Exposes task status and details for UI components to show loading states and errors
|
||||
*
|
||||
* URL patterns:
|
||||
* - /conversations/task-{uuid} → Polls start task, then navigates to /conversations/{conversation-id}
|
||||
* - /conversations/{uuid or hex} → No polling (handled by useActiveConversation)
|
||||
*
|
||||
* Note: This hook does NOT fetch conversation data. It only handles task polling and navigation.
|
||||
*/
|
||||
export const useTaskPolling = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if this is a task ID (format: "task-{uuid}")
|
||||
const isTask = conversationId.startsWith("task-");
|
||||
const taskId = isTask ? conversationId.replace("task-", "") : null;
|
||||
|
||||
// Poll the task if this is a task ID
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["start-task", taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return null;
|
||||
return V1ConversationService.getStartTask(taskId);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
const task = query.state.data;
|
||||
if (!task) return false;
|
||||
|
||||
// Stop polling if ready or error
|
||||
if (task.status === "READY" || task.status === "ERROR") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Poll every 3 seconds while task is in progress
|
||||
return 3000;
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Navigate to conversation ID when task is ready
|
||||
useEffect(() => {
|
||||
const task = taskQuery.data;
|
||||
if (task?.status === "READY" && task.app_conversation_id) {
|
||||
// Replace the URL with the actual conversation ID
|
||||
navigate(`/conversations/${task.app_conversation_id}`, { replace: true });
|
||||
}
|
||||
}, [taskQuery.data, navigate]);
|
||||
|
||||
return {
|
||||
isTask,
|
||||
taskId,
|
||||
conversationId: isTask ? null : conversationId,
|
||||
task: taskQuery.data,
|
||||
taskStatus: taskQuery.data?.status,
|
||||
taskDetail: taskQuery.data?.detail,
|
||||
taskError: taskQuery.error,
|
||||
isLoadingTask: taskQuery.isLoading,
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||
const FIFTEEN_MINUTES = 1000 * 60 * 15;
|
||||
|
||||
type RefetchInterval = (
|
||||
query: Query<
|
||||
Conversation | null,
|
||||
@@ -23,11 +22,7 @@ export const useUserConversation = (
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: async () => {
|
||||
if (!cid) return null;
|
||||
|
||||
// Use the legacy GET endpoint - it handles both V0 and V1 conversations
|
||||
// V1 conversations are automatically detected by UUID format and converted
|
||||
const conversation = await ConversationService.getConversation(cid);
|
||||
const conversation = await ConversationService.getConversation(cid!);
|
||||
return conversation;
|
||||
},
|
||||
enabled: !!cid,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
@@ -17,31 +15,13 @@ interface VSCodeUrlResult {
|
||||
export const useVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
return useQuery<VSCodeUrlResult>({
|
||||
queryKey: [
|
||||
"vscode_url",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
conversation?.url,
|
||||
conversation?.session_api_key,
|
||||
],
|
||||
queryKey: ["vscode_url", conversationId],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// Use appropriate API based on conversation version
|
||||
const data = isV1Conversation
|
||||
? await V1ConversationService.getVSCodeUrl(
|
||||
conversationId,
|
||||
conversation?.url,
|
||||
conversation?.session_api_key,
|
||||
)
|
||||
: await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
if (data.vscode_url) {
|
||||
return {
|
||||
url: transformVSCodeUrl(data.vscode_url),
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { V1AgentStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
/**
|
||||
* Maps V1 agent status to V0 AgentState
|
||||
*/
|
||||
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
|
||||
if (!status) {
|
||||
return AgentState.LOADING;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case V1AgentStatus.IDLE:
|
||||
return AgentState.AWAITING_USER_INPUT;
|
||||
case V1AgentStatus.RUNNING:
|
||||
return AgentState.RUNNING;
|
||||
case V1AgentStatus.PAUSED:
|
||||
return AgentState.PAUSED;
|
||||
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
|
||||
return AgentState.AWAITING_USER_CONFIRMATION;
|
||||
case V1AgentStatus.FINISHED:
|
||||
return AgentState.FINISHED;
|
||||
case V1AgentStatus.ERROR:
|
||||
return AgentState.ERROR;
|
||||
case V1AgentStatus.STUCK:
|
||||
return AgentState.ERROR; // Map STUCK to ERROR for now
|
||||
default:
|
||||
return AgentState.LOADING;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current agent state
|
||||
* - For V0 conversations: Returns state from useAgentStore
|
||||
* - For V1 conversations: Returns mapped state from useV1ConversationStateStore
|
||||
*/
|
||||
export function useAgentState() {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0State = useAgentStore((state) => state.curAgentState);
|
||||
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const curAgentState = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
return mapV1StatusToV0State(v1Status);
|
||||
}
|
||||
return v0State;
|
||||
}, [isV1Conversation, v1Status, v0State]);
|
||||
|
||||
return { curAgentState };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user