diff --git a/.openhands/microagents/repo.md b/.openhands/microagents/repo.md
index 9b6c76d3de..73dfbf0da4 100644
--- a/.openhands/microagents/repo.md
+++ b/.openhands/microagents/repo.md
@@ -33,6 +33,7 @@ Frontend:
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
+ - Our test framework is vitest
- Building:
- Build for production: `npm run build`
- Environment Variables:
diff --git a/.openhands/setup.sh b/.openhands/setup.sh
new file mode 100644
index 0000000000..91df10179d
--- /dev/null
+++ b/.openhands/setup.sh
@@ -0,0 +1,5 @@
+#! /bin/bash
+
+echo "Setting up the environment..."
+
+python -m pip install pre-commit
diff --git a/frontend/src/components/features/controls/controls.tsx b/frontend/src/components/features/controls/controls.tsx
index abf8143a96..eda0cfdb11 100644
--- a/frontend/src/components/features/controls/controls.tsx
+++ b/frontend/src/components/features/controls/controls.tsx
@@ -5,6 +5,7 @@ import { AgentStatusBar } from "./agent-status-bar";
import { SecurityLock } from "./security-lock";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
+import { useAutoTitle } from "#/hooks/use-auto-title";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@@ -16,6 +17,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useUserConversation(
params.conversationId ?? null,
);
+ useAutoTitle();
return (
diff --git a/frontend/src/hooks/use-auto-title.ts b/frontend/src/hooks/use-auto-title.ts
new file mode 100644
index 0000000000..ac5f048775
--- /dev/null
+++ b/frontend/src/hooks/use-auto-title.ts
@@ -0,0 +1,82 @@
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useSelector, useDispatch } from "react-redux";
+import { useQueryClient } from "@tanstack/react-query";
+import { useUpdateConversation } from "./mutation/use-update-conversation";
+import { RootState } from "#/store";
+import OpenHands from "#/api/open-hands";
+import { useUserConversation } from "#/hooks/query/use-user-conversation";
+
+const defaultTitlePattern = /^Conversation [a-f0-9]+$/;
+
+/**
+ * Hook that monitors for the first agent message and triggers title generation.
+ * This approach is more robust as it ensures the user message has been processed
+ * by the backend and the agent has responded before generating the title.
+ */
+export function useAutoTitle() {
+ const { conversationId } = useParams<{ conversationId: string }>();
+ const { data: conversation } = useUserConversation(conversationId ?? null);
+ const queryClient = useQueryClient();
+ const dispatch = useDispatch();
+ const { mutate: updateConversation } = useUpdateConversation();
+
+ const messages = useSelector((state: RootState) => state.chat.messages);
+
+ useEffect(() => {
+ if (
+ !conversation ||
+ !conversationId ||
+ !messages ||
+ messages.length === 0
+ ) {
+ return;
+ }
+
+ const hasAgentMessage = messages.some(
+ (message) => message.sender === "assistant",
+ );
+ const hasUserMessage = messages.some(
+ (message) => message.sender === "user",
+ );
+
+ if (!hasAgentMessage || !hasUserMessage) {
+ return;
+ }
+
+ if (conversation.title && !defaultTitlePattern.test(conversation.title)) {
+ return;
+ }
+
+ updateConversation(
+ {
+ id: conversationId,
+ conversation: { title: "" },
+ },
+ {
+ onSuccess: async () => {
+ try {
+ const updatedConversation =
+ await OpenHands.getConversation(conversationId);
+
+ queryClient.setQueryData(
+ ["user", "conversation", conversationId],
+ updatedConversation,
+ );
+ } catch (error) {
+ queryClient.invalidateQueries({
+ queryKey: ["user", "conversation", conversationId],
+ });
+ }
+ },
+ },
+ );
+ }, [
+ messages,
+ conversationId,
+ conversation,
+ updateConversation,
+ queryClient,
+ dispatch,
+ ]);
+}
diff --git a/frontend/src/hooks/use-document-title-from-state.ts b/frontend/src/hooks/use-document-title-from-state.ts
new file mode 100644
index 0000000000..92021a3234
--- /dev/null
+++ b/frontend/src/hooks/use-document-title-from-state.ts
@@ -0,0 +1,30 @@
+import { useParams } from "react-router";
+import { useEffect, useRef } from "react";
+import { useUserConversation } from "./query/use-user-conversation";
+
+/**
+ * Hook that updates the document title based on the current conversation.
+ * This ensures that any changes to the conversation title are reflected in the document title.
+ *
+ * @param suffix Optional suffix to append to the title (default: "OpenHands")
+ */
+export function useDocumentTitleFromState(suffix = "OpenHands") {
+ const params = useParams();
+ const { data: conversation } = useUserConversation(
+ params.conversationId ?? null,
+ );
+ const lastValidTitleRef = useRef(null);
+
+ useEffect(() => {
+ if (conversation?.title) {
+ lastValidTitleRef.current = conversation.title;
+ document.title = `${conversation.title} - ${suffix}`;
+ } else {
+ document.title = suffix;
+ }
+
+ return () => {
+ document.title = suffix;
+ };
+ }, [conversation, suffix]);
+}
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 84597f27e5..a5f2f9aa12 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -7,6 +7,8 @@ from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
+from openhands.events.event import EventSource
+from openhands.events.stream import EventStream
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
)
@@ -26,6 +28,7 @@ from openhands.server.shared import (
SettingsStoreImpl,
config,
conversation_manager,
+ file_store,
)
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
@@ -95,10 +98,7 @@ async def _create_new_conversation(
extra={'user_id': user_id, 'session_id': conversation_id},
)
- repository_title = (
- selected_repository.split('/')[-1] if selected_repository else None
- )
- conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}'
+ conversation_title = get_default_conversation_title(conversation_id)
logger.info(f'Saving metadata for conversation {conversation_id}')
await conversation_store.save_metadata(
@@ -244,16 +244,78 @@ async def get_conversation(
return None
+def get_default_conversation_title(conversation_id: str) -> str:
+ """
+ Generate a default title for a conversation based on its ID.
+
+ Args:
+ conversation_id: The ID of the conversation
+
+ Returns:
+ A default title string
+ """
+ return f'Conversation {conversation_id[:5]}'
+
+
+async def auto_generate_title(conversation_id: str, user_id: str | None) -> str:
+ """
+ Auto-generate a title for a conversation based on the first user message.
+
+ Args:
+ conversation_id: The ID of the conversation
+ user_id: The ID of the user
+
+ Returns:
+ A generated title string
+ """
+ logger.info(f'Auto-generating title for conversation {conversation_id}')
+
+ try:
+ # Create an event stream for the conversation
+ event_stream = EventStream(conversation_id, file_store, user_id)
+
+ # Find the first user message
+ first_user_message = None
+ for event in event_stream.get_events():
+ if (
+ event.source == EventSource.USER
+ and isinstance(event, MessageAction)
+ and event.content
+ and event.content.strip()
+ ):
+ first_user_message = event.content
+ break
+
+ if first_user_message:
+ first_user_message = first_user_message.strip()
+ title = first_user_message[:15]
+ if len(first_user_message) > 15:
+ title += '...'
+ logger.info(f'Generated title: {title}')
+ return title
+ except Exception as e:
+ logger.error(f'Error generating title: {str(e)}')
+ return ''
+
+
@app.patch('/conversations/{conversation_id}')
async def update_conversation(
request: Request, conversation_id: str, title: str = Body(embed=True)
) -> bool:
+ user_id = get_user_id(request)
conversation_store = await ConversationStoreImpl.get_instance(
- config, get_user_id(request), get_github_user_id(request)
+ config, user_id, get_github_user_id(request)
)
metadata = await conversation_store.get_metadata(conversation_id)
if not metadata:
return False
+
+ # If title is empty or unspecified, auto-generate it from the first user message
+ if not title or title.isspace():
+ title = await auto_generate_title(conversation_id, user_id)
+ if not title:
+ title = get_default_conversation_title(conversation_id)
+
metadata.title = title
await conversation_store.save_metadata(metadata)
return True
@@ -287,7 +349,7 @@ async def _get_conversation_info(
try:
title = conversation.title
if not title:
- title = f'Conversation {conversation.conversation_id[:5]}'
+ title = get_default_conversation_title(conversation.conversation_id)
return ConversationInfo(
conversation_id=conversation.conversation_id,
title=title,