Compare commits

...

6 Commits

10 changed files with 294 additions and 2 deletions
@@ -1,5 +1,5 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { FaListUl, FaRobot } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { NavLink, useLocation } from "react-router";
@@ -105,6 +105,20 @@ export function Sidebar() {
)}
/>
</TooltipButton>
<NavLink
to="/agents"
className={({ isActive }) =>
`${isActive ? "text-white" : "text-[#9099AC]"}`
}
>
<TooltipButton
testId="agents-page"
tooltip={t(I18nKey.AGENTS$ACTIVE_AGENTS)}
ariaLabel={t(I18nKey.AGENTS$ACTIVE_AGENTS)}
>
<FaRobot size={22} />
</TooltipButton>
</NavLink>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
+2
View File
@@ -1,5 +1,7 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
AGENTS$ACTIVE_AGENTS = "AGENTS$ACTIVE_AGENTS",
AGENTS$NO_ACTIVE_AGENTS = "AGENTS$NO_ACTIVE_AGENTS",
HOME$CONNECT_PROVIDER_MESSAGE = "HOME$CONNECT_PROVIDER_MESSAGE",
HOME$LETS_START_BUILDING = "HOME$LETS_START_BUILDING",
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
+30
View File
@@ -1,4 +1,34 @@
{
"AGENTS$ACTIVE_AGENTS": {
"en": "Active Agents",
"ja": "アクティブなエージェント",
"zh-CN": "活跃的代理",
"zh-TW": "活躍的代理",
"ko-KR": "활성 에이전트",
"no": "Aktive agenter",
"it": "Agenti attivi",
"pt": "Agentes ativos",
"es": "Agentes activos",
"ar": "العملاء النشطين",
"fr": "Agents actifs",
"tr": "Aktif Ajanlar",
"de": "Aktive Agenten"
},
"AGENTS$NO_ACTIVE_AGENTS": {
"en": "No active agents found",
"ja": "アクティブなエージェントが見つかりません",
"zh-CN": "未找到活跃的代理",
"zh-TW": "未找到活躍的代理",
"ko-KR": "활성 에이전트를 찾을 수 없습니다",
"no": "Ingen aktive agenter funnet",
"it": "Nessun agente attivo trovato",
"pt": "Nenhum agente ativo encontrado",
"es": "No se encontraron agentes activos",
"ar": "لم يتم العثور على عملاء نشطين",
"fr": "Aucun agent actif trouvé",
"tr": "Aktif ajan bulunamadı",
"de": "Keine aktiven Agenten gefunden"
},
"HOME$CONNECT_PROVIDER_MESSAGE": {
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
+1
View File
@@ -8,6 +8,7 @@ import {
export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("agents", "routes/agents.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
route("billing", "routes/billing.tsx"),
+63
View File
@@ -0,0 +1,63 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { NavLink } from "react-router";
export default function AgentsPage() {
const { t } = useTranslation();
const { data: conversations, isFetching, error } = useUserConversations();
// Filter only active (RUNNING) conversations
const activeConversations = conversations?.filter(
(conversation) => conversation.status === "RUNNING"
);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">{t(I18nKey.AGENTS$ACTIVE_AGENTS)}</h1>
{isFetching && (
<div className="w-full flex justify-center items-center py-12">
<LoadingSpinner size="medium" />
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-danger">{error.message}</p>
</div>
)}
{activeConversations?.length === 0 && !isFetching && (
<div className="flex flex-col items-center justify-center py-12 text-neutral-400">
<p>{t(I18nKey.AGENTS$NO_ACTIVE_AGENTS)}</p>
</div>
)}
<div className="grid grid-cols-1 gap-4">
{activeConversations?.map((conversation) => (
<NavLink
key={conversation.conversation_id}
to={`/conversations/${conversation.conversation_id}`}
>
{({ isActive }) => (
<ConversationCard
isActive={isActive}
title={conversation.title}
selectedRepository={conversation.selected_repository}
lastUpdatedAt={conversation.last_updated_at}
createdAt={conversation.created_at}
status={conversation.status}
conversationId={conversation.conversation_id}
showOptions={true}
/>
)}
</NavLink>
))}
</div>
</div>
);
}
+93
View File
@@ -636,6 +636,99 @@ class Runtime(FileEditRuntimeMixin):
def get_git_diff(self, file_path: str, cwd: str) -> dict[str, str]:
self.git_handler.set_cwd(cwd)
return self.git_handler.get_git_diff(file_path)
def get_git_branch(self, cwd: str) -> str | None:
"""
Get the current git branch for the repository.
Args:
cwd (str): The current working directory.
Returns:
str | None: The current branch name or None if not a git repository.
"""
self.git_handler.set_cwd(cwd)
# Check if it's a git repository
if not self.git_handler._is_git_repo():
return None
return self.git_handler._get_current_branch()
def get_git_repository(self, cwd: str) -> str | None:
"""
Get the remote repository URL for the git repository.
Args:
cwd (str): The current working directory.
Returns:
str | None: The repository name in the format "owner/repo" or None if not found.
"""
self.git_handler.set_cwd(cwd)
# Check if it's a git repository
if not self.git_handler._is_git_repo():
return None
# Get the remote URL
cmd = 'git remote -v'
output = self.git_handler.execute(cmd, self.git_handler.cwd)
if output.exit_code != 0 or not output.content.strip():
return None
# Parse the remote URL to extract the repository
import re
match = re.search(r'(github\.com|gitlab\.com)[:/]([^/\s]+/[^/\s]+)(?:\.git|\s)', output.content)
if match:
return match.group(2)
return None
def get_git_commit_state(self, cwd: str) -> str | None:
"""
Get the commit state for the git repository.
Args:
cwd (str): The current working directory.
Returns:
str | None: "CLEAN" if no changes, "IN_PROGRESS" if there are uncommitted changes
or unpushed commits, None if not a git repository.
"""
from openhands.storage.data_models.conversation_metadata import CommitState
self.git_handler.set_cwd(cwd)
# Check if it's a git repository
if not self.git_handler._is_git_repo():
return None
# Check for uncommitted changes
cmd = 'git status --porcelain'
output = self.git_handler.execute(cmd, self.git_handler.cwd)
if output.exit_code != 0:
return None
# If there are uncommitted changes, return IN_PROGRESS
if output.content.strip():
return CommitState.IN_PROGRESS
# Get current branch
current_branch = self.git_handler._get_current_branch()
# Check if there are commits not pushed to origin
cmd = f'git rev-list HEAD...origin/{current_branch} --count 2>/dev/null || echo "0"'
output = self.git_handler.execute(cmd, self.git_handler.cwd)
# If there are unpushed commits, return IN_PROGRESS
if output.content.strip() != '0':
return CommitState.IN_PROGRESS
# If we got here, everything is clean
return CommitState.CLEAN
@property
def additional_agent_instructions(self) -> str:
@@ -12,6 +12,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.event_store import EventStore
from openhands.events.observation import CmdOutputObservation
from openhands.events.stream import EventStreamSubscriber, session_exists
from openhands.server.config.server_config import ServerConfig
from openhands.server.monitoring import MonitoringListener
@@ -463,6 +464,34 @@ class StandaloneConversationManager(ConversationManager):
token_usage.prompt_tokens + token_usage.completion_tokens
)
if isinstance(event, CmdOutputObservation) and 'git ' in event.command:
print('GIT OBSERVATION')
convo = await self.attach_to_conversation(conversation_id, user_id)
if convo:
runtime = convo.runtime
cwd = '' # FIXME, use runtime.git_dir once it's implemented
branch = runtime.get_git_branch(cwd)
if branch and branch != conversation.selected_branch:
conversation.selected_branch = branch
logger.info(
f'Updated branch for conversation {conversation_id}: {branch}'
)
repository = runtime.get_git_repository(cwd)
if repository and repository != conversation.selected_repository:
conversation.selected_repository = repository
logger.info(
f'Updated repository for conversation {conversation_id}: {repository}'
)
commit_state = runtime.get_git_commit_state(cwd)
if commit_state is not None:
# conversation.commit_state = str(commit_state)
logger.info(
f'Updated commit state for conversation {conversation_id}: {commit_state}'
)
await conversation_store.save_metadata(conversation)
@@ -1,7 +1,7 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import CommitState, ConversationTrigger
from openhands.storage.data_models.conversation_status import ConversationStatus
@@ -17,5 +17,7 @@ class ConversationInfo:
last_updated_at: datetime | None = None
status: ConversationStatus = ConversationStatus.STOPPED
selected_repository: str | None = None
selected_branch: str | None = None
commit_state: CommitState | None = None
trigger: ConversationTrigger | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+50
View File
@@ -1,4 +1,8 @@
import os
import re
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from fastapi import (
APIRouter,
@@ -10,14 +14,17 @@ from fastapi import (
from fastapi.responses import FileResponse, JSONResponse
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from pydantic import BaseModel
from starlette.background import BackgroundTask
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
CmdRunAction,
FileReadAction,
)
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
)
@@ -38,6 +45,10 @@ from openhands.storage.data_models.conversation_metadata import ConversationMeta
from openhands.storage.data_models.conversation_status import ConversationStatus
from openhands.utils.async_utils import call_sync_from_async
# Import CommitState from conversation_metadata
from openhands.storage.data_models.conversation_metadata import CommitState
app = APIRouter(prefix='/api/conversations/{conversation_id}')
@@ -255,6 +266,43 @@ async def git_diff(
)
@app.get('/git/info')
async def get_git_info(
request: Request,
conversation_id: str,
user_id: str = Depends(get_user_id),
):
"""Get git information for the conversation's repository.
This endpoint returns the current branch, repository, and commit state
from the conversation metadata.
Returns:
dict: A dictionary containing branch, repository, and commit state information.
"""
conversation_store = await ConversationStoreImpl.get_instance(
config,
user_id,
)
try:
# Get the conversation metadata
metadata = await conversation_store.get_metadata(conversation_id)
# Return the git information
return {
'branch': metadata.selected_branch,
'repository': metadata.selected_repository,
'commit_state': metadata.commit_state,
}
except Exception as e:
logger.error(f'Error getting git info: {e}')
return JSONResponse(
status_code=500,
content={'error': str(e)},
)
async def get_cwd(
conversation_store: ConversationStore,
conversation_id: str,
@@ -286,6 +334,8 @@ async def _get_conversation_info(
last_updated_at=conversation.last_updated_at,
created_at=conversation.created_at,
selected_repository=conversation.selected_repository,
selected_branch=conversation.selected_branch,
commit_state=conversation.commit_state,
status=ConversationStatus.RUNNING
if is_running
else ConversationStatus.STOPPED,
@@ -8,6 +8,12 @@ class ConversationTrigger(Enum):
GUI = 'gui'
class CommitState(str, Enum):
"""Enum representing the state of git commits in a repository."""
CLEAN = "CLEAN" # No changes, current commit matches origin commit for the same branch
IN_PROGRESS = "IN_PROGRESS" # There are uncommitted changes or local commits not in origin
@dataclass
class ConversationMetadata:
conversation_id: str
@@ -19,6 +25,8 @@ class ConversationMetadata:
last_updated_at: datetime | None = None
trigger: ConversationTrigger | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Git state
commit_state: CommitState | None = None
# Cost and token metrics
accumulated_cost: float = 0.0
prompt_tokens: int = 0