mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): display plan preview content (#12504)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}{" "}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
114
frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts
Normal file
114
frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts
Normal 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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user