Compare commits

..

4 Commits

Author SHA1 Message Date
Graham Neubig 7ef6824327 Merge branch 'main' into openhands/fix-websocket-warning-log-level 2025-10-15 16:06:37 -04:00
openhands c325630920 Remove test for websocket log level
As requested, removing the test since the behavior is well-documented
and the change is simple (log level only).

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-15 20:05:27 +00:00
openhands b466f61b98 Fix linting issues in test_session.py
Apply pre-commit formatting fixes:
- Remove trailing whitespace
- Fix line breaks and indentation
- Reformat list comprehensions per ruff style guide

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-15 20:03:44 +00:00
openhands 085c1ca591 fix: change websocket initialization log level from warning to debug
The message 'There is no listening client in the current room' was being
logged at WARNING level during normal websocket initialization. This was
generating 1000+ warnings per day in production DataDog logs.

This is expected behavior - the code intentionally waits up to 2 seconds
for the websocket client to join the room during initialization. Each
retry attempt (every 0.1s) was logging a warning, but this is not an
error condition.

Changes:
- Changed log level from logger.warning() to logger.debug() in session.py
- Added test to verify the log level is debug (not warning) during
  websocket initialization wait

Fixes #677

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-15 17:57:53 +00:00
170 changed files with 1704 additions and 15505 deletions
+7 -26
View File
@@ -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:**
+2 -4
View File
@@ -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()
+2 -4
View File
@@ -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()
+18 -3925
View File
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
+1 -13
View File
@@ -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
+1 -12
View File
@@ -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'],
)
+3 -16
View File
@@ -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
+2 -3
View File
@@ -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("?");
});
});
});
@@ -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;
}
-1
View File
@@ -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);
};
@@ -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>
@@ -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} />
) : (
@@ -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,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}
@@ -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>
);
@@ -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>
@@ -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>>(
{},
);
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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} />
@@ -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 */}
</>
);
}
@@ -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";
@@ -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}
/>
);
}
@@ -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}
/>
);
}
-8
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "./chat";
+3 -8
View File
@@ -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,
+2 -22
View File
@@ -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),
-56
View File
@@ -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