From 31d508116369700f2f25c2db1f0f42965433c6bd Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:19:57 +0700 Subject: [PATCH] feat(frontend): display plan preview content (#12504) --- .../features/chat/plan-preview.test.tsx | 159 ++++++++++++++ .../should-render-event.test.ts | 35 ++++ .../chat/event-message-plan-preview.test.tsx | 159 ++++++++++++++ .../hooks/use-plan-preview-events.test.ts | 195 ++++++++++++++++++ .../components/features/chat/plan-preview.tsx | 52 +++-- .../should-render-event.ts | 5 + .../src/components/v1/chat/event-message.tsx | 23 +++ .../v1/chat/hooks/use-plan-preview-events.ts | 114 ++++++++++ frontend/src/components/v1/chat/messages.tsx | 6 + frontend/src/types/v1/core/base/action.ts | 32 +++ frontend/test-utils.tsx | 104 ++++++++++ 11 files changed, 869 insertions(+), 15 deletions(-) create mode 100644 frontend/__tests__/components/features/chat/plan-preview.test.tsx create mode 100644 frontend/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts create mode 100644 frontend/__tests__/components/v1/chat/event-message-plan-preview.test.tsx create mode 100644 frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts create mode 100644 frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts diff --git a/frontend/__tests__/components/features/chat/plan-preview.test.tsx b/frontend/__tests__/components/features/chat/plan-preview.test.tsx new file mode 100644 index 0000000000..c240a33912 --- /dev/null +++ b/frontend/__tests__/components/features/chat/plan-preview.test.tsx @@ -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(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + }; +}); + +describe("PlanPreview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render nothing when planContent is null", () => { + renderWithProviders(); + + const contentDiv = screen.getByTestId("plan-preview-content"); + expect(contentDiv).toBeInTheDocument(); + expect(contentDiv.textContent?.trim() || "").toBe(""); + }); + + it("should render nothing when planContent is undefined", () => { + renderWithProviders(); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + expect(container.textContent).toContain("Heading 1"); + expect(container.textContent).toContain("Heading 2"); + }); +}); diff --git a/frontend/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts b/frontend/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts new file mode 100644 index 0000000000..1dc6f4301a --- /dev/null +++ b/frontend/__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts @@ -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); + }); +}); diff --git a/frontend/__tests__/components/v1/chat/event-message-plan-preview.test.tsx b/frontend/__tests__/components/v1/chat/event-message-plan-preview.test.tsx new file mode 100644 index 0000000000..7f00eaa7d4 --- /dev/null +++ b/frontend/__tests__/components/v1/chat/event-message-plan-preview.test.tsx @@ -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 }) => ( +
Plan Preview: {planContent || "null"}
+ ), +})); + +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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + + const { container } = renderWithProviders( + , + ); + + expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts b/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts new file mode 100644 index 0000000000..ee5f61556e --- /dev/null +++ b/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts @@ -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 => ({ + 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(); + + expect(shouldShowPlanPreview("event-1", planPreviewEventIds)).toBe(false); + }); +}); diff --git a/frontend/src/components/features/chat/plan-preview.tsx b/frontend/src/components/features/chat/plan-preview.tsx index 71504c54bc..6848abea01 100644 --- a/frontend/src/components/features/chat/plan-preview.tsx +++ b/frontend/src/components/features/chat/plan-preview.tsx @@ -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" > {t(I18nKey.COMMON$VIEW)} @@ -50,16 +60,27 @@ export function PlanPreview({ {/* Content */} -
-

- {title} -

-

- {description} - - {t(I18nKey.COMMON$READ_MORE)} - -

+
+ {truncatedContent && ( + <> + + {truncatedContent} + + {planContent && planContent.length > MAX_CONTENT_LENGTH && ( + + )} + + )}
{/* 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" > {t(I18nKey.COMMON$BUILD)}{" "} diff --git a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts index 1171c21c92..b20bedc4a9 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts @@ -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; } diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 95690d8984..415f9854bd 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -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; } /** @@ -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 ; + } + // 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, diff --git a/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts b/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts new file mode 100644 index 0000000000..b36c97786b --- /dev/null +++ b/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts @@ -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 { + return useMemo(() => { + const planPreviewEventIds = new Set(); + + // 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, +): boolean { + return planPreviewEventIds.has(eventId); +} diff --git a/frontend/src/components/v1/chat/messages.tsx b/frontend/src/components/v1/chat/messages.tsx index 4c4b733e76..b0a4450282 100644 --- a/frontend/src/components/v1/chat/messages.tsx +++ b/frontend/src/components/v1/chat/messages.tsx @@ -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 = 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 = 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} diff --git a/frontend/src/types/v1/core/base/action.ts b/frontend/src/types/v1/core/base/action.ts index 8d3ec41bff..2fcef92087 100644 --- a/frontend/src/types/v1/core/base/action.ts +++ b/frontend/src/types/v1/core/base/action.ts @@ -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 diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index 0d2a55f51d..fee6d71093 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -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 => ({ + 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: [], +});