+ );
+}
diff --git a/src/components/conversation-events/chat/event-message-components/error-event-message.tsx b/src/components/conversation-events/chat/event-message-components/error-event-message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1b26c39c88d4b5bcda962903536917a810d4b658
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/error-event-message.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+import { AgentErrorEvent } from "#/types/agent-server/core";
+import { isAgentErrorEvent } from "#/types/agent-server/type-guards";
+import { ErrorMessage } from "../../../features/chat/error-message";
+
+interface ErrorEventMessageProps {
+ event: AgentErrorEvent;
+}
+
+export function ErrorEventMessage({ event }: ErrorEventMessageProps) {
+ if (!isAgentErrorEvent(event)) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/src/components/conversation-events/chat/event-message-components/event-group.tsx b/src/components/conversation-events/chat/event-message-components/event-group.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..22f61eb3a8806256331c2b877a4b7d2b76e337d3
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/event-group.tsx
@@ -0,0 +1,169 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { LoaderCircle } from "lucide-react";
+import ArrowDown from "#/icons/angle-down-solid.svg?react";
+import ArrowUp from "#/icons/angle-up-solid.svg?react";
+import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core";
+import {
+ isActionEvent,
+ isObservationEvent,
+} from "#/types/agent-server/type-guards";
+import { I18nKey } from "#/i18n/declaration";
+import { getEventContent } from "../event-content-helpers/get-event-content";
+import { IsInEventGroupContext } from "../../../features/chat/is-in-event-group-context";
+import { PathInteractiveContext } from "../../../features/chat/path-component";
+
+interface EventGroupProps {
+ /** The events represented by this group. Used to compute the summary. */
+ events: OpenHandsEvent[];
+ /**
+ * Full event history. Used to resolve the action that produced the latest
+ * observation in the group so the summary title matches what the individual
+ * card would show (e.g. "Editing path/to/file"). Falls back to `events` when
+ * omitted.
+ */
+ allEvents?: OpenHandsEvent[];
+ /**
+ * `true` once an event outside this group has been emitted after it, so the
+ * group is no longer the "live" tail of the chat. While `false` (the
+ * default), the group keeps showing the most recent action's title as its
+ * prominent summary, with the count of completed actions shown subtly on
+ * the right.
+ */
+ isFinalized?: boolean;
+ /** The fully-rendered event messages to show when the group is expanded. */
+ children: React.ReactNode;
+}
+
+/**
+ * Collapsible container that wraps a run of consecutive agent action/observation
+ * events into a single summary card.
+ *
+ * Collapsed, while the group is still the live tail of the chat
+ * (`isFinalized=false`):
+ * - Left (prominent): the title of the most recent action/observation in
+ * the group — i.e. either the action currently in flight, or the latest
+ * completed step.
+ * - Right (subdued): "{completed}/{total} actions completed" while at
+ * least one action is still pending (with a spinner), otherwise
+ * "{count} actions completed" followed by a success check.
+ *
+ * Collapsed, after the group has been "moved past" (`isFinalized=true`):
+ * - "{count} actions completed" is promoted to the prominent foreground
+ * style and the count is the only thing shown next to the chevron.
+ *
+ * Expanded:
+ * - Renders the children verbatim, so each individual action/observation can
+ * still be expanded the way it was before grouping.
+ */
+export function EventGroup({
+ events,
+ allEvents,
+ isFinalized = false,
+ children,
+}: EventGroupProps) {
+ const { t } = useTranslation("openhands");
+ const [expanded, setExpanded] = React.useState(false);
+ const contentId = React.useId();
+ const buttonId = `${contentId}-toggle`;
+
+ if (events.length === 0) {
+ return null;
+ }
+
+ // Each ObservationEvent in the group is a completed action. An ActionEvent
+ // that's still here (i.e. not yet replaced by its observation in the UI
+ // events array) is an action currently in flight.
+ const pendingAction = events.find((e): e is ActionEvent => isActionEvent(e));
+ const completedCount = events.filter(isObservationEvent).length;
+ const totalCount = events.length;
+ const isRunning = !!pendingAction;
+
+ // Title of the most recent groupable event. While running this is the
+ // pending action; otherwise it's the latest observation, with its
+ // originating action looked up so the title can be the action-style summary
+ // ("Editing path/to/file") instead of the observation default.
+ const latestEvent = events[events.length - 1];
+ let latestTitle: React.ReactNode = null;
+ if (latestEvent) {
+ if (isActionEvent(latestEvent)) {
+ latestTitle = getEventContent(latestEvent).title;
+ } else if (isObservationEvent(latestEvent)) {
+ const lookupSource = allEvents ?? events;
+ const correspondingAction = lookupSource.find(
+ (e): e is ActionEvent =>
+ isActionEvent(e) && e.id === latestEvent.action_id,
+ );
+ latestTitle = getEventContent(latestEvent, correspondingAction).title;
+ }
+ }
+
+ const countSummary = isRunning
+ ? t(I18nKey.EVENT_GROUP$ACTIONS_PROGRESS, {
+ completed: completedCount,
+ total: totalCount,
+ })
+ : t(I18nKey.EVENT_GROUP$ACTIONS_COMPLETED, { count: totalCount });
+
+ const Chevron = expanded ? ArrowUp : ArrowDown;
+
+ return (
+
+
+
+ {expanded && (
+
+
+ {children}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx b/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..244086262ce4b3e463366024fd591b85bf8d0f49
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx
@@ -0,0 +1,35 @@
+import { ActionEvent } from "#/types/agent-server/core";
+import { FinishAction } from "#/types/agent-server/core/base/action";
+import { ChatMessage } from "../../../features/chat/chat-message";
+import { getEventContent } from "../event-content-helpers/get-event-content";
+import { CriticResultDisplay } from "./critic-result-display";
+
+interface FinishEventMessageProps {
+ event: ActionEvent;
+ isFromPlanningAgent?: boolean;
+}
+
+export function FinishEventMessage({
+ event,
+ isFromPlanningAgent = false,
+}: FinishEventMessageProps) {
+ const eventContent = getEventContent(event);
+ const message =
+ typeof eventContent.details === "string"
+ ? eventContent.details
+ : String(eventContent.details);
+
+ return (
+ <>
+
+ {event.critic_result != null && (
+
+ )}
+ >
+ );
+}
diff --git a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..21c0bf270c7a8c0db092ec9c536a5dce9960b10a
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx
@@ -0,0 +1,127 @@
+import {
+ OpenHandsEvent,
+ ActionEvent,
+ ObservationEvent,
+} from "#/types/agent-server/core";
+import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation";
+import { I18nKey } from "#/i18n/declaration";
+import { GenericEventMessage } from "../../../features/chat/generic-event-message";
+import { getEventContent } from "../event-content-helpers/get-event-content";
+import {
+ getACPToolCallResult,
+ getObservationResult,
+ ObservationResultStatus,
+} from "../event-content-helpers/get-observation-result";
+import {
+ isACPToolCallEvent,
+ isObservationEvent,
+} from "#/types/agent-server/type-guards";
+import {
+ SkillReadyEvent,
+ SkillReadyItem,
+ isSkillReadyEvent,
+} from "../event-content-helpers/create-skill-ready-event";
+import { getInvokeSkillItems } from "../event-content-helpers/get-invoke-skill-items";
+import { SkillReadyContentList } from "./skill-ready-content-list";
+import SkillsIcon from "#/icons/skills.svg?react";
+import { isMarkdownFileEditorEvent } from "#/components/features/chat/tool-visualizers/primitives/markdown-file-preview";
+
+interface GenericEventMessageWrapperProps {
+ event: OpenHandsEvent | SkillReadyEvent;
+ isLastMessage: boolean;
+ correspondingAction?: ActionEvent;
+}
+
+/**
+ * Resolves the expandable skill-knowledge list shared by Skill Ready events and
+ * invoke-skill tool observations. Returns the list items and their header
+ * label, or null when the event carries no skill knowledge (so the caller keeps
+ * the default details body). Additional skill-knowledge sources slot in as one
+ * more branch here.
+ */
+function getSkillKnowledge(
+ event: OpenHandsEvent | SkillReadyEvent,
+): { items: SkillReadyItem[]; titleKey: I18nKey } | null {
+ if (isSkillReadyEvent(event)) {
+ return event._skillReadyItems.length > 0
+ ? {
+ items: event._skillReadyItems,
+ titleKey: I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE,
+ }
+ : null;
+ }
+ if (
+ isObservationEvent(event) &&
+ event.observation.kind === "InvokeSkillObservation"
+ ) {
+ const items = getInvokeSkillItems(
+ event as ObservationEvent,
+ );
+ return items.length > 0
+ ? { items, titleKey: I18nKey.SKILLS$INVOKED_SKILL_KNOWLEDGE }
+ : null;
+ }
+ return null;
+}
+
+export function GenericEventMessageWrapper({
+ event,
+ correspondingAction,
+}: GenericEventMessageWrapperProps) {
+ const { title, details } = getEventContent(event, correspondingAction);
+
+ // TaskTrackerObservation has its own rendering
+ if (
+ !isSkillReadyEvent(event) &&
+ isObservationEvent(event) &&
+ event.observation.kind === "TaskTrackerObservation"
+ ) {
+ return
{details}
;
+ }
+
+ // Determine success status
+ let success: ObservationResultStatus | undefined;
+ if (isSkillReadyEvent(event)) {
+ success = "success";
+ } else if (isObservationEvent(event)) {
+ success = getObservationResult(event);
+ } else if (isACPToolCallEvent(event)) {
+ success = getACPToolCallResult(event);
+ }
+
+ // Skill Ready events and invoke-skill tool observations both render the
+ // expandable skill-knowledge list (with the skills icon); they differ only in
+ // the header label.
+ const skillKnowledge = getSkillKnowledge(event);
+ const bodyDetails = skillKnowledge ? (
+
+ ) : (
+ details
+ );
+
+ // Markdown file-editor cards carry a clipped preview; expand them by
+ // default so the artifact is visible without an extra chevron click.
+ const initiallyExpanded =
+ !isSkillReadyEvent(event) &&
+ isMarkdownFileEditorEvent(event, correspondingAction);
+
+ return (
+
+
+ ) : undefined
+ }
+ />
+
+ );
+}
diff --git a/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx b/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4bec021ab798a9a405727d00a772bd0719c9a8fb
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx
@@ -0,0 +1 @@
+export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
diff --git a/src/components/conversation-events/chat/event-message-components/index.ts b/src/components/conversation-events/chat/event-message-components/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4da5c75b3de4b32546f03276dd7f9c160caf28b0
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/index.ts
@@ -0,0 +1,8 @@
+export { UserAssistantEventMessage } from "./user-assistant-event-message";
+export { ErrorEventMessage } from "./error-event-message";
+export { FinishEventMessage } from "./finish-event-message";
+export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
+export { ThoughtEventMessage } from "./thought-event-message";
+export { HookExecutionEventMessage } from "./hook-execution-event-message";
+export { EventGroup } from "./event-group";
+export { CriticResultDisplay } from "./critic-result-display";
diff --git a/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx b/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9da328d8fb91213af06c4c2ff6212f8a67d4368
--- /dev/null
+++ b/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx
@@ -0,0 +1,96 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { METADATA_PREFIXES } from "#/utils/constants";
+import { Typography } from "#/ui/typography";
+import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer";
+
+interface ParsedSkillContent {
+ matchInfo: string | null;
+ filePath: string | null;
+ body: string;
+}
+
+/**
+ * Parses skill content into metadata (keyword match info, file path)
+ * and the actual skill body.
+ */
+export function parseSkillContent(content: string): ParsedSkillContent {
+ const lines = content.split("\n");
+ let matchInfo: string | null = null;
+ let filePath: string | null = null;
+ let bodyStartIndex = 0;
+
+ for (let i = 0; i < lines.length; i += 1) {
+ const line = lines[i];
+
+ if (line.startsWith(METADATA_PREFIXES[0])) {
+ matchInfo = line.trim();
+ bodyStartIndex = i + 1;
+ } else if (line.startsWith(METADATA_PREFIXES[2])) {
+ filePath = line.replace(METADATA_PREFIXES[2], "").trim();
+ bodyStartIndex = i + 1;
+ } else if (
+ line.startsWith(METADATA_PREFIXES[1]) ||
+ line.startsWith(METADATA_PREFIXES[3])
+ ) {
+ bodyStartIndex = i + 1;
+ } else if (line.trim() === "" && i <= bodyStartIndex) {
+ bodyStartIndex = i + 1;
+ } else {
+ break;
+ }
+ }
+
+ return {
+ matchInfo,
+ filePath,
+ body: lines.slice(bodyStartIndex).join("\n").trim(),
+ };
+}
+
+/**
+ * Wraps `...` tags in the content with bold markers
+ * so the markdown renderer displays them as bold text.
+ */
+export function styleImportantTags(text: string): string {
+ return text.replace(
+ /([\s\S]*?)<\/important>/gi,
+ (_match, inner) => `**${inner.trim()}**`,
+ );
+}
+
+interface SkillItemExpandedProps {
+ content: string;
+}
+
+export function SkillItemExpanded({ content }: SkillItemExpandedProps) {
+ const { t } = useTranslation("openhands");
+ const { matchInfo, filePath, body } = parseSkillContent(content);
+ const hasMetadata = matchInfo || filePath;
+
+ return (
+