{
- selectTab("planner");
+ navigateToTab("planner");
};
// Handle Build action with scroll to bottom
diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
index 7c2f45e365..a29d853694 100644
--- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
+++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx
@@ -1,6 +1,5 @@
import { OpenHandsEvent } from "#/types/v1/core";
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
-import { ChatMessage } from "../../../features/chat/chat-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";
@@ -14,13 +13,11 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he
interface GenericEventMessageWrapperProps {
event: OpenHandsEvent | SkillReadyEvent;
isLastMessage: boolean;
- isFromPlanningAgent?: boolean;
}
export function GenericEventMessageWrapper({
event,
isLastMessage,
- isFromPlanningAgent = false,
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
@@ -30,17 +27,6 @@ export function GenericEventMessageWrapper({
if (event.observation.kind === "TaskTrackerObservation") {
return
{details}
;
}
- if (event.observation.kind === "FinishObservation") {
- const message = typeof details === "string" ? details : String(details);
- // Use ChatMessage for proper styling (blue border for planning agent, text-sm)
- return (
-
- );
- }
}
}
diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx
index 01383e0271..0c55a9a0b4 100644
--- a/frontend/src/components/v1/chat/event-message.tsx
+++ b/frontend/src/components/v1/chat/event-message.tsx
@@ -121,7 +121,6 @@ const renderUserMessageWithSkillReady = (
>
);
@@ -212,7 +211,6 @@ export function EventMessage({
>
);
@@ -261,7 +259,6 @@ export function EventMessage({
>
);
@@ -292,10 +289,6 @@ export function EventMessage({
// Generic fallback for all other events
return (
-
+
);
}
diff --git a/frontend/src/hooks/use-handle-build-plan-click.ts b/frontend/src/hooks/use-handle-build-plan-click.ts
index b058a11deb..cc4edc9652 100644
--- a/frontend/src/hooks/use-handle-build-plan-click.ts
+++ b/frontend/src/hooks/use-handle-build-plan-click.ts
@@ -24,7 +24,7 @@ export const useHandleBuildPlanClick = () => {
setConversationMode("code");
// Create the build prompt to execute the plan
- const buildPrompt = `Execute the plan based on the workspace/project/PLAN.md file.`;
+ const buildPrompt = `Execute the plan based on the .agents_tmp/PLAN.md file.`;
// Send the message to the code agent
const timestamp = new Date().toISOString();
diff --git a/frontend/src/hooks/use-select-conversation-tab.ts b/frontend/src/hooks/use-select-conversation-tab.ts
index 4012d676f2..14f688ff16 100644
--- a/frontend/src/hooks/use-select-conversation-tab.ts
+++ b/frontend/src/hooks/use-select-conversation-tab.ts
@@ -48,6 +48,19 @@ export function useSelectConversationTab() {
}
};
+ /**
+ * Navigates to a tab without toggle behavior.
+ * Always shows the panel and selects the tab, even if already selected.
+ * Use this for "View" or "Read More" buttons that should always navigate.
+ */
+ const navigateToTab = (tab: ConversationTab) => {
+ onTabChange(tab);
+ if (!isRightPanelShown) {
+ setHasRightPanelToggled(true);
+ setPersistedRightPanelShown(true);
+ }
+ };
+
/**
* Checks if a specific tab is currently active (selected and panel is visible).
*/
@@ -56,6 +69,7 @@ export function useSelectConversationTab() {
return {
selectTab,
+ navigateToTab,
isTabActive,
onTabChange,
selectedTab,
diff --git a/frontend/src/utils/handle-event-for-ui.ts b/frontend/src/utils/handle-event-for-ui.ts
index fe00605005..9fa409f01c 100644
--- a/frontend/src/utils/handle-event-for-ui.ts
+++ b/frontend/src/utils/handle-event-for-ui.ts
@@ -19,6 +19,13 @@ export const handleEventForUI = (
return newUiEvents;
}
+ // Don't add FinishObservation at all - we keep the FinishAction instead
+ // Both contain the same message content, so we only need to display one
+ // This also prevents duplicate messages when events arrive out of order due to React batching
+ if (event.observation.kind === "FinishObservation") {
+ return newUiEvents;
+ }
+
// Find and replace the corresponding action from uiEvents
const actionIndex = newUiEvents.findIndex(
(uiEvent) => uiEvent.id === event.action_id,
diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
index d8d355e9af..0772aae509 100644
--- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py
+++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
@@ -569,6 +569,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model
+ def _compute_plan_path(
+ self,
+ working_dir: str,
+ git_provider: ProviderType | None,
+ ) -> str:
+ """Compute the PLAN.md path based on provider type.
+
+ Args:
+ working_dir: The workspace working directory
+ git_provider: The git provider type (GitHub, GitLab, Azure DevOps, etc.)
+
+ Returns:
+ Absolute path to PLAN.md file in the appropriate config directory
+ """
+ # GitLab and Azure DevOps use agents-tmp-config (since .agents_tmp is invalid)
+ if git_provider in (ProviderType.GITLAB, ProviderType.AZURE_DEVOPS):
+ config_dir = 'agents-tmp-config'
+ else:
+ config_dir = '.agents_tmp'
+
+ return f'{working_dir}/{config_dir}/PLAN.md'
+
async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
"""Set up secrets for all git provider authentication.
@@ -855,6 +877,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config: dict,
condenser_max_size: int | None,
secrets: dict[str, SecretValue] | None = None,
+ git_provider: ProviderType | None = None,
+ working_dir: str | None = None,
) -> Agent:
"""Create an agent with appropriate tools and context based on agent type.
@@ -865,6 +889,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config: MCP configuration dictionary
condenser_max_size: condenser_max_size setting
secrets: Optional dictionary of secrets for authentication
+ git_provider: Optional git provider type for computing plan path
+ working_dir: Optional working directory for computing plan path
Returns:
Configured Agent instance with context
@@ -874,9 +900,14 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Create agent based on type
if agent_type == AgentType.PLAN:
+ # Compute plan path if working_dir is provided
+ plan_path = None
+ if working_dir:
+ plan_path = self._compute_plan_path(working_dir, git_provider)
+
agent = Agent(
llm=llm,
- tools=get_planning_tools(),
+ tools=get_planning_tools(plan_path=plan_path),
system_prompt_filename='system_prompt_planning.j2',
system_prompt_kwargs={'plan_structure': format_plan_structure()},
condenser=condenser,
@@ -1153,6 +1184,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config,
user.condenser_max_size,
secrets=secrets,
+ git_provider=git_provider,
+ working_dir=working_dir,
)
# Finalize and return the conversation request
diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py
index 9bcd383a60..dbc8c8b71e 100644
--- a/tests/unit/app_server/test_live_status_app_conversation_service.py
+++ b/tests/unit/app_server/test_live_status_app_conversation_service.py
@@ -682,6 +682,41 @@ class TestLiveStatusAppConversationService:
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
)
+ def test_compute_plan_path_default_uses_agents_tmp(self):
+ """Test _compute_plan_path returns .agents_tmp/PLAN.md for default/GitHub."""
+ # Arrange
+ working_dir = '/workspace/project'
+
+ # Act
+ path_none = self.service._compute_plan_path(working_dir, None)
+ path_github = self.service._compute_plan_path(working_dir, ProviderType.GITHUB)
+
+ # Assert
+ assert path_none == '/workspace/project/.agents_tmp/PLAN.md'
+ assert path_github == '/workspace/project/.agents_tmp/PLAN.md'
+
+ def test_compute_plan_path_gitlab_uses_agents_tmp_config(self):
+ """Test _compute_plan_path returns agents-tmp-config/PLAN.md for GitLab."""
+ # Arrange
+ working_dir = '/workspace/project'
+
+ # Act
+ path = self.service._compute_plan_path(working_dir, ProviderType.GITLAB)
+
+ # Assert
+ assert path == '/workspace/project/agents-tmp-config/PLAN.md'
+
+ def test_compute_plan_path_azure_uses_agents_tmp_config(self):
+ """Test _compute_plan_path returns agents-tmp-config/PLAN.md for Azure."""
+ # Arrange
+ working_dir = '/workspace/project'
+
+ # Act
+ path = self.service._compute_plan_path(working_dir, ProviderType.AZURE_DEVOPS)
+
+ # Assert
+ assert path == '/workspace/project/agents-tmp-config/PLAN.md'
+
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
)
@@ -704,6 +739,8 @@ class TestLiveStatusAppConversationService:
mock_format_plan.return_value = 'test_plan_structure'
mcp_config = {'default': {'url': 'test'}}
system_message_suffix = 'Test suffix'
+ working_dir = '/workspace/project'
+ git_provider = ProviderType.GITHUB
# Act
with patch(
@@ -719,9 +756,14 @@ class TestLiveStatusAppConversationService:
system_message_suffix,
mcp_config,
self.mock_user.condenser_max_size,
+ git_provider=git_provider,
+ working_dir=working_dir,
)
# Assert
+ mock_get_tools.assert_called_once_with(
+ plan_path='/workspace/project/.agents_tmp/PLAN.md'
+ )
mock_agent_class.assert_called_once()
call_kwargs = mock_agent_class.call_args[1]
assert call_kwargs['llm'] == mock_llm
@@ -1006,6 +1048,8 @@ class TestLiveStatusAppConversationService:
mock_mcp_config,
self.mock_user.condenser_max_size,
secrets=mock_secrets,
+ git_provider=ProviderType.GITHUB,
+ working_dir='/test/dir',
)
self.service._finalize_conversation_request.assert_called_once()