feat(frontend): display plan preview content (#12504)

This commit is contained in:
Hiep Le
2026-01-26 23:19:57 +07:00
committed by GitHub
parent 250736cb7a
commit 31d5081163
11 changed files with 869 additions and 15 deletions

View File

@@ -0,0 +1,159 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { PlanPreview } from "#/components/features/chat/plan-preview";
// Mock the feature flag to always return true (not testing feature flag behavior)
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: vi.fn(() => true),
}));
// Mock i18n - need to preserve initReactI18next and I18nextProvider for test-utils
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
};
});
describe("PlanPreview", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render nothing when planContent is null", () => {
renderWithProviders(<PlanPreview planContent={null} />);
const contentDiv = screen.getByTestId("plan-preview-content");
expect(contentDiv).toBeInTheDocument();
expect(contentDiv.textContent?.trim() || "").toBe("");
});
it("should render nothing when planContent is undefined", () => {
renderWithProviders(<PlanPreview planContent={undefined} />);
const contentDiv = screen.getByTestId("plan-preview-content");
expect(contentDiv).toBeInTheDocument();
expect(contentDiv.textContent?.trim() || "").toBe("");
});
it("should render markdown content when planContent is provided", () => {
const planContent = "# Plan Title\n\nThis is the plan content.";
const { container } = renderWithProviders(
<PlanPreview planContent={planContent} />,
);
// Check that component rendered and contains the content (markdown may break up text)
expect(container.firstChild).not.toBeNull();
expect(container.textContent).toContain("Plan Title");
expect(container.textContent).toContain("This is the plan content.");
});
it("should render full content when length is less than or equal to 300 characters", () => {
const planContent = "A".repeat(300);
const { container } = renderWithProviders(
<PlanPreview planContent={planContent} />,
);
// Content should be present (may be broken up by markdown)
expect(container.textContent).toContain(planContent);
expect(screen.queryByText(/COMMON\$READ_MORE/i)).not.toBeInTheDocument();
});
it("should truncate content when length exceeds 300 characters", () => {
const longContent = "A".repeat(350);
const { container } = renderWithProviders(
<PlanPreview planContent={longContent} />,
);
// Truncated content should be present (may be broken up by markdown)
expect(container.textContent).toContain("A".repeat(300));
expect(container.textContent).toContain("...");
expect(container.textContent).toContain("COMMON$READ_MORE");
});
it("should call onViewClick when View button is clicked", async () => {
const user = userEvent.setup();
const onViewClick = vi.fn();
renderWithProviders(
<PlanPreview planContent="Plan content" onViewClick={onViewClick} />,
);
const viewButton = screen.getByTestId("plan-preview-view-button");
expect(viewButton).toBeInTheDocument();
await user.click(viewButton);
expect(onViewClick).toHaveBeenCalledTimes(1);
});
it("should call onViewClick when Read More button is clicked", async () => {
const user = userEvent.setup();
const onViewClick = vi.fn();
const longContent = "A".repeat(350);
renderWithProviders(
<PlanPreview planContent={longContent} onViewClick={onViewClick} />,
);
const readMoreButton = screen.getByTestId("plan-preview-read-more-button");
expect(readMoreButton).toBeInTheDocument();
await user.click(readMoreButton);
expect(onViewClick).toHaveBeenCalledTimes(1);
});
it("should call onBuildClick when Build button is clicked", async () => {
const user = userEvent.setup();
const onBuildClick = vi.fn();
renderWithProviders(
<PlanPreview planContent="Plan content" onBuildClick={onBuildClick} />,
);
const buildButton = screen.getByTestId("plan-preview-build-button");
expect(buildButton).toBeInTheDocument();
await user.click(buildButton);
expect(onBuildClick).toHaveBeenCalledTimes(1);
});
it("should render header with PLAN_MD text", () => {
const { container } = renderWithProviders(
<PlanPreview planContent="Plan content" />,
);
// Check that the translation key is rendered (i18n mock returns the key)
expect(container.textContent).toContain("COMMON$PLAN_MD");
});
it("should render plan content", () => {
const planContent = `# Heading 1
## Heading 2
- List item 1
- List item 2
**Bold text** and *italic text*`;
const { container } = renderWithProviders(
<PlanPreview planContent={planContent} />,
);
expect(container.textContent).toContain("Heading 1");
expect(container.textContent).toContain("Heading 2");
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { shouldRenderEvent } from "#/components/v1/chat/event-content-helpers/should-render-event";
import {
createPlanningFileEditorActionEvent,
createOtherActionEvent,
createPlanningObservationEvent,
createUserMessageEvent,
} from "test-utils";
describe("shouldRenderEvent - PlanningFileEditorAction", () => {
it("should return false for PlanningFileEditorAction", () => {
const event = createPlanningFileEditorActionEvent("action-1");
expect(shouldRenderEvent(event)).toBe(false);
});
it("should return true for other action types", () => {
const event = createOtherActionEvent("action-1");
expect(shouldRenderEvent(event)).toBe(true);
});
it("should return true for PlanningFileEditorObservation", () => {
const event = createPlanningObservationEvent("obs-1");
// Observations should still render (they're handled separately in event-message)
expect(shouldRenderEvent(event)).toBe(true);
});
it("should return true for user message events", () => {
const event = createUserMessageEvent("msg-1");
expect(shouldRenderEvent(event)).toBe(true);
});
});

View File

@@ -0,0 +1,159 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { screen, render } from "@testing-library/react";
import { EventMessage } from "#/components/v1/chat/event-message";
import { useConversationStore } from "#/stores/conversation-store";
import {
renderWithProviders,
createPlanningObservationEvent,
} from "test-utils";
// Mock the feature flag
vi.mock("#/utils/feature-flags", () => ({
USE_PLANNING_AGENT: vi.fn(() => true),
}));
// Mock useConfig
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { APP_MODE: "saas" },
}),
}));
// Mock PlanPreview component to verify it's rendered
vi.mock("#/components/features/chat/plan-preview", () => ({
PlanPreview: ({ planContent }: { planContent?: string | null }) => (
<div data-testid="plan-preview">Plan Preview: {planContent || "null"}</div>
),
}));
describe("EventMessage - PlanPreview rendering", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset conversation store
useConversationStore.setState({
planContent: null,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render PlanPreview when PlanningFileEditorObservation event ID is in planPreviewEventIds", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const planPreviewEventIds = new Set(["plan-obs-1"]);
const planContent = "This is the plan content";
useConversationStore.setState({ planContent });
renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={planPreviewEventIds}
/>,
);
expect(screen.getByTestId("plan-preview")).toBeInTheDocument();
expect(
screen.getByText(`Plan Preview: ${planContent}`),
).toBeInTheDocument();
});
it("should return null when PlanningFileEditorObservation event ID is NOT in planPreviewEventIds", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const planPreviewEventIds = new Set(["plan-obs-2"]); // Different ID
const { container } = renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={planPreviewEventIds}
/>,
);
expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
expect(container.firstChild).toBeNull();
});
it("should return null when planPreviewEventIds is undefined", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const { container } = renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={undefined}
/>,
);
expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
expect(container.firstChild).toBeNull();
});
it("should use planContent from conversation store", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const planPreviewEventIds = new Set(["plan-obs-1"]);
const planContent = "Store plan content";
useConversationStore.setState({ planContent });
renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={planPreviewEventIds}
/>,
);
expect(
screen.getByText(`Plan Preview: ${planContent}`),
).toBeInTheDocument();
});
it("should handle null planContent from store", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const planPreviewEventIds = new Set(["plan-obs-1"]);
useConversationStore.setState({ planContent: null });
renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={planPreviewEventIds}
/>,
);
expect(screen.getByTestId("plan-preview")).toBeInTheDocument();
expect(screen.getByText("Plan Preview: null")).toBeInTheDocument();
});
it("should handle empty planPreviewEventIds set", () => {
const event = createPlanningObservationEvent("plan-obs-1");
const planPreviewEventIds = new Set<string>();
const { container } = renderWithProviders(
<EventMessage
event={event}
messages={[]}
isLastMessage={false}
isInLast10Actions={false}
planPreviewEventIds={planPreviewEventIds}
/>,
);
expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument();
expect(container.firstChild).toBeNull();
});
});

View File

@@ -0,0 +1,195 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
usePlanPreviewEvents,
shouldShowPlanPreview,
} from "#/components/v1/chat/hooks/use-plan-preview-events";
import {
OpenHandsEvent,
MessageEvent,
ObservationEvent,
PlanningFileEditorObservation,
} from "#/types/v1/core";
// Helper to create a user message event
const createUserMessageEvent = (id: string): MessageEvent => ({
id,
timestamp: new Date().toISOString(),
source: "user",
llm_message: {
role: "user",
content: [{ type: "text", text: "User message" }],
},
activated_microagents: [],
extended_content: [],
});
// Helper to create a PlanningFileEditorObservation event
const createPlanningObservationEvent = (
id: string,
actionId: string = "action-1",
): ObservationEvent<PlanningFileEditorObservation> => ({
id,
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "planning_file_editor",
tool_call_id: "call-1",
action_id: actionId,
observation: {
kind: "PlanningFileEditorObservation",
content: [{ type: "text", text: "Plan content" }],
is_error: false,
command: "create",
path: "/workspace/PLAN.md",
prev_exist: false,
old_content: null,
new_content: "Plan content",
},
});
// Helper to create a non-planning observation event
const createOtherObservationEvent = (id: string): ObservationEvent => ({
id,
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "execute_bash",
tool_call_id: "call-1",
action_id: "action-1",
observation: {
kind: "ExecuteBashObservation",
content: [{ type: "text", text: "output" }],
command: "echo test",
exit_code: 0,
error: false,
timeout: false,
metadata: {
exit_code: 0,
pid: 12345,
username: "user",
hostname: "localhost",
working_dir: "/home/user",
py_interpreter_path: null,
prefix: "",
suffix: "",
},
},
});
describe("usePlanPreviewEvents", () => {
it("should return empty set when no events provided", () => {
const { result } = renderHook(() => usePlanPreviewEvents([]));
expect(result.current).toBeInstanceOf(Set);
expect(result.current.size).toBe(0);
});
it("should return empty set when no PlanningFileEditorObservation events exist", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createOtherObservationEvent("obs-1"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(0);
});
it("should return event ID for single PlanningFileEditorObservation in one phase", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
it("should return only the last PlanningFileEditorObservation when multiple exist in one phase", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1"),
createPlanningObservationEvent("plan-obs-2"),
createPlanningObservationEvent("plan-obs-3"),
createOtherObservationEvent("other-obs-1"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
// Should only include the last one in the phase
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(false);
expect(result.current.has("plan-obs-2")).toBe(false);
expect(result.current.has("plan-obs-3")).toBe(true);
});
it("should return one event ID per phase when multiple phases exist", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1"),
createPlanningObservationEvent("plan-obs-2"),
createUserMessageEvent("user-2"),
createPlanningObservationEvent("plan-obs-3"),
createPlanningObservationEvent("plan-obs-4"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
// Should have one preview per phase (last observation in each phase)
expect(result.current.size).toBe(2);
expect(result.current.has("plan-obs-2")).toBe(true); // Last in phase 1
expect(result.current.has("plan-obs-4")).toBe(true); // Last in phase 2
expect(result.current.has("plan-obs-1")).toBe(false);
expect(result.current.has("plan-obs-3")).toBe(false);
});
it("should handle phase with no PlanningFileEditorObservation", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createOtherObservationEvent("obs-1"),
createUserMessageEvent("user-2"),
createPlanningObservationEvent("plan-obs-1"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
// Only phase 2 has a planning observation
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
it("should handle events starting with non-user message", () => {
const events: OpenHandsEvent[] = [
createOtherObservationEvent("obs-1"),
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
// Events before first user message should be in first phase
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
});
describe("shouldShowPlanPreview", () => {
it("should return true when event ID is in the set", () => {
const planPreviewEventIds = new Set(["event-1", "event-2", "event-3"]);
expect(shouldShowPlanPreview("event-2", planPreviewEventIds)).toBe(true);
});
it("should return false when event ID is not in the set", () => {
const planPreviewEventIds = new Set(["event-1", "event-2"]);
expect(shouldShowPlanPreview("event-3", planPreviewEventIds)).toBe(false);
});
it("should return false when set is empty", () => {
const planPreviewEventIds = new Set<string>();
expect(shouldShowPlanPreview("event-1", planPreviewEventIds)).toBe(false);
});
});

View File

@@ -1,22 +1,24 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight } from "lucide-react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer";
const MAX_CONTENT_LENGTH = 300;
interface PlanPreviewProps {
title?: string;
description?: string;
/** Raw plan content from PLAN.md file */
planContent?: string | null;
onViewClick?: () => void;
onBuildClick?: () => void;
}
// TODO: Remove the hardcoded values and use the plan content from the conversation store
/* eslint-disable i18next/no-literal-string */
export function PlanPreview({
title = "Improve Developer Onboarding and Examples",
description = "Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered...",
planContent,
onViewClick,
onBuildClick,
}: PlanPreviewProps) {
@@ -24,6 +26,13 @@ export function PlanPreview({
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
// Truncate plan content for preview
const truncatedContent = useMemo(() => {
if (!planContent) return "";
if (planContent.length <= MAX_CONTENT_LENGTH) return planContent;
return `${planContent.slice(0, MAX_CONTENT_LENGTH)}...`;
}, [planContent]);
if (!shouldUsePlanningAgent) {
return null;
}
@@ -41,6 +50,7 @@ export function PlanPreview({
type="button"
onClick={onViewClick}
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
data-testid="plan-preview-view-button"
>
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
{t(I18nKey.COMMON$VIEW)}
@@ -50,16 +60,27 @@ export function PlanPreview({
</div>
{/* Content */}
<div className="flex flex-col gap-[10px] p-4">
<h3 className="font-bold text-[19px] text-white leading-[29px]">
{title}
</h3>
<p className="text-[15px] text-white leading-[29px]">
{description}
<Typography.Text className="text-[#4a67bd] cursor-pointer hover:underline ml-1">
{t(I18nKey.COMMON$READ_MORE)}
</Typography.Text>
</p>
<div
data-testid="plan-preview-content"
className="flex flex-col gap-[10px] p-4 text-[15px] text-white leading-[29px]"
>
{truncatedContent && (
<>
<MarkdownRenderer includeStandard includeHeadings>
{truncatedContent}
</MarkdownRenderer>
{planContent && planContent.length > MAX_CONTENT_LENGTH && (
<button
type="button"
onClick={onViewClick}
className="text-[#4a67bd] cursor-pointer hover:underline text-left"
data-testid="plan-preview-read-more-button"
>
{t(I18nKey.COMMON$READ_MORE)}
</button>
)}
</>
)}
</div>
{/* Footer */}
@@ -68,6 +89,7 @@ export function PlanPreview({
type="button"
onClick={onBuildClick}
className="bg-white flex items-center justify-center h-[26px] px-2 rounded-[4px] w-[93px] hover:opacity-90 transition-opacity cursor-pointer"
data-testid="plan-preview-build-button"
>
<Typography.Text className="font-medium text-[14px] text-black leading-5">
{t(I18nKey.COMMON$BUILD)}{" "}

View File

@@ -27,6 +27,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return false;
}
// Hide PlanningFileEditorAction - handled separately with PlanPreview component
if (actionType === "PlanningFileEditorAction") {
return false;
}
return true;
}

View File

@@ -6,9 +6,11 @@ import {
isObservationEvent,
isAgentErrorEvent,
isUserMessageEvent,
isPlanningFileEditorObservationEvent,
} from "#/types/v1/type-guards";
import { MicroagentStatus } from "#/types/microagent-status";
import { useConfig } from "#/hooks/query/use-config";
import { useConversationStore } from "#/stores/conversation-store";
// TODO: Implement V1 feedback functionality when API supports V1 event IDs
// import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
import {
@@ -19,6 +21,8 @@ import {
ThoughtEventMessage,
} from "./event-message-components";
import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event";
import { PlanPreview } from "../../features/chat/plan-preview";
import { shouldShowPlanPreview } from "./hooks/use-plan-preview-events";
interface EventMessageProps {
event: OpenHandsEvent & { isFromPlanningAgent?: boolean };
@@ -33,6 +37,8 @@ interface EventMessageProps {
tooltip?: string;
}>;
isInLast10Actions: boolean;
/** Set of event IDs that should render PlanPreview (one per user message phase) */
planPreviewEventIds?: Set<string>;
}
/**
@@ -143,8 +149,10 @@ export function EventMessage({
microagentPRUrl,
actions,
isInLast10Actions,
planPreviewEventIds,
}: EventMessageProps) {
const { data: config } = useConfig();
const { planContent } = useConversationStore();
// V1 events use string IDs, but useFeedbackExists expects number
// For now, we'll skip feedback functionality for V1 events
@@ -198,6 +206,21 @@ export function EventMessage({
// Observation events - find the corresponding action and render thought + observation
if (isObservationEvent(event)) {
// Handle PlanningFileEditorObservation specially
if (isPlanningFileEditorObservationEvent(event)) {
// Only show PlanPreview if this event is marked as the one to display
// (last PlanningFileEditorObservation in its phase)
if (
planPreviewEventIds &&
shouldShowPlanPreview(event.id, planPreviewEventIds)
) {
return <PlanPreview planContent={planContent} />;
}
// Not the designated preview event for this phase - render nothing
// This prevents duplicate previews within the same phase
return null;
}
// Find the action that this observation is responding to
const correspondingAction = messages.find(
(msg) => isActionEvent(msg) && msg.id === event.action_id,

View File

@@ -0,0 +1,114 @@
import { useMemo } from "react";
import { OpenHandsEvent } from "#/types/v1/core";
import {
isUserMessageEvent,
isPlanningFileEditorObservationEvent,
} from "#/types/v1/type-guards";
/**
* Groups events into phases based on user messages.
* A phase starts with a user message and includes all subsequent events
* until the next user message.
*
* @param events - The full list of events
* @returns Array of phases, where each phase is an array of events
*/
function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] {
const phases: OpenHandsEvent[][] = [];
let currentPhase: OpenHandsEvent[] = [];
for (const event of events) {
if (isUserMessageEvent(event)) {
// Start a new phase with the user message
if (currentPhase.length > 0) {
phases.push(currentPhase);
}
currentPhase = [event];
} else {
// Add event to current phase
currentPhase.push(event);
}
}
// Don't forget the last phase
if (currentPhase.length > 0) {
phases.push(currentPhase);
}
return phases;
}
/**
* Finds the last PlanningFileEditorObservation in a phase.
*
* @param phase - Array of events in a phase
* @returns The event ID of the last PlanningFileEditorObservation, or null
*/
function findLastPlanningObservationInPhase(
phase: OpenHandsEvent[],
): string | null {
// Iterate backwards to find the last one
for (let i = phase.length - 1; i >= 0; i -= 1) {
const event = phase[i];
if (isPlanningFileEditorObservationEvent(event)) {
return event.id;
}
}
return null;
}
export interface PlanPreviewEventInfo {
eventId: string;
/** Index of this plan preview in the conversation (1st, 2nd, etc.) */
phaseIndex: number;
}
/**
* Hook to determine which PlanningFileEditorObservation events should render PlanPreview.
*
* This hook implements phase-based grouping where:
* - A phase starts with a user message and ends at the next user message
* - Only the LAST PlanningFileEditorObservation in each phase shows PlanPreview
* - This ensures only one preview per user request, even with multiple observations
*
* Scenario handling:
* - Scenario 1 (Create plan): Multiple observations in one phase → 1 preview
* - Scenario 2 (Create then update): Two user messages → two phases → 2 previews
* - Scenario 3 (Create + update while processing): Two user messages → 2 previews
*
* @param allEvents - Full list of v1 events (for phase detection)
* @returns Set of event IDs that should render PlanPreview
*/
export function usePlanPreviewEvents(allEvents: OpenHandsEvent[]): Set<string> {
return useMemo(() => {
const planPreviewEventIds = new Set<string>();
// Group events by phases (user message boundaries)
const phases = groupEventsByPhase(allEvents);
// For each phase, find the last PlanningFileEditorObservation
phases.forEach((phase) => {
const lastPlanningObservationId =
findLastPlanningObservationInPhase(phase);
if (lastPlanningObservationId) {
planPreviewEventIds.add(lastPlanningObservationId);
}
});
return planPreviewEventIds;
}, [allEvents]);
}
/**
* Check if a specific event should render PlanPreview.
*
* @param eventId - The event ID to check
* @param planPreviewEventIds - Set of event IDs that should render PlanPreview
* @returns true if this event should render PlanPreview
*/
export function shouldShowPlanPreview(
eventId: string,
planPreviewEventIds: Set<string>,
): boolean {
return planPreviewEventIds.has(eventId);
}

View File

@@ -3,6 +3,7 @@ import { OpenHandsEvent } from "#/types/v1/core";
import { EventMessage } from "./event-message";
import { ChatMessage } from "../../features/chat/chat-message";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { usePlanPreviewEvents } from "./hooks/use-plan-preview-events";
// 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";
@@ -18,6 +19,10 @@ export const Messages: React.FC<MessagesProps> = React.memo(
const optimisticUserMessage = getOptimisticUserMessage();
// Get the set of event IDs that should render PlanPreview
// This ensures only one preview per user message "phase"
const planPreviewEventIds = usePlanPreviewEvents(allEvents);
// TODO: Implement microagent functionality for V1 if needed
// For now, we'll skip microagent features
@@ -30,6 +35,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
messages={allEvents}
isLastMessage={messages.length - 1 === index}
isInLast10Actions={messages.length - 1 - index < 10}
planPreviewEventIds={planPreviewEventIds}
// Microagent props - not implemented yet for V1
// microagentStatus={undefined}
// microagentConversationId={undefined}

View File

@@ -213,6 +213,37 @@ export interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction
tab_id: string;
}
export interface PlanningFileEditorAction extends ActionBase<"PlanningFileEditorAction"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
/**
* Absolute path to file (typically /workspace/project/PLAN.md).
*/
path: string;
/**
* Required parameter of `create` command, with the content of the file to be created.
*/
file_text: string | null;
/**
* Required parameter of `str_replace` command containing the string in `path` to replace.
*/
old_str: string | null;
/**
* Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
*/
new_str: string | null;
/**
* Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. Must be >= 1.
*/
insert_line: number | null;
/**
* Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown.
*/
view_range: [number, number] | null;
}
export type Action =
| MCPToolAction
| FinishAction
@@ -222,6 +253,7 @@ export type Action =
| FileEditorAction
| StrReplaceEditorAction
| TaskTrackerAction
| PlanningFileEditorAction
| BrowserNavigateAction
| BrowserClickAction
| BrowserTypeAction

View File

@@ -7,6 +7,13 @@ import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { vi } from "vitest";
import { AxiosError } from "axios";
import {
ActionEvent,
MessageEvent,
ObservationEvent,
PlanningFileEditorObservation,
} from "#/types/v1/core";
import { SecurityRisk } from "#/types/v1/core";
export const useParamsMock = vi.fn(() => ({
conversationId: "test-conversation-id",
@@ -98,3 +105,100 @@ export const createAxiosError = (
config: {},
},
);
// Helper to create a PlanningFileEditorAction event
export const createPlanningFileEditorActionEvent = (
id: string,
): ActionEvent => ({
id,
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Planning action" }],
thinking_blocks: [],
action: {
kind: "PlanningFileEditorAction",
command: "create",
path: "/workspace/PLAN.md",
file_text: "Plan content",
old_str: null,
new_str: null,
insert_line: null,
view_range: null,
},
tool_name: "planning_file_editor",
tool_call_id: "call-1",
tool_call: {
id: "call-1",
type: "function",
function: {
name: "planning_file_editor",
arguments: '{"command": "create"}',
},
},
llm_response_id: "response-1",
security_risk: SecurityRisk.UNKNOWN,
});
// Helper to create a non-planning action event
export const createOtherActionEvent = (id: string): ActionEvent => ({
id,
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Other action" }],
thinking_blocks: [],
action: {
kind: "ExecuteBashAction",
command: "echo test",
is_input: false,
timeout: null,
reset: false,
},
tool_name: "execute_bash",
tool_call_id: "call-1",
tool_call: {
id: "call-1",
type: "function",
function: {
name: "execute_bash",
arguments: '{"command": "echo test"}',
},
},
llm_response_id: "response-1",
security_risk: SecurityRisk.UNKNOWN,
});
// Helper to create a PlanningFileEditorObservation event
export const createPlanningObservationEvent = (
id: string,
actionId: string = "action-1",
): ObservationEvent<PlanningFileEditorObservation> => ({
id,
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "planning_file_editor",
tool_call_id: "call-1",
action_id: actionId,
observation: {
kind: "PlanningFileEditorObservation",
content: [{ type: "text", text: "Plan content" }],
is_error: false,
command: "create",
path: "/workspace/PLAN.md",
prev_exist: false,
old_content: null,
new_content: "Plan content",
},
});
// Helper to create a user message event
export const createUserMessageEvent = (id: string): MessageEvent => ({
id,
timestamp: new Date().toISOString(),
source: "user",
llm_message: {
role: "user",
content: [{ type: "text", text: "User message" }],
},
activated_microagents: [],
extended_content: [],
});