feat(frontend):Display path of file ops and cmd in headline (#7530)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Carlos Freund <carlosfreund@gmail.com>
This commit is contained in:
Carlos Freund
2025-04-08 14:44:42 +02:00
committed by GitHub
parent 9fa211bc27
commit dd03d9adce
8 changed files with 327 additions and 152 deletions

View File

@@ -23,7 +23,7 @@ vi.mock("react-i18next", async () => {
describe("ExpandableMessage", () => {
it("should render with neutral border for non-action messages", () => {
renderWithProviders(<ExpandableMessage message="Hello" type="thought" />);
const element = screen.getByText("Hello");
const element = screen.getAllByText("Hello")[0];
const container = element.closest(
"div.flex.gap-2.items-center.justify-start",
);
@@ -35,7 +35,7 @@ describe("ExpandableMessage", () => {
renderWithProviders(
<ExpandableMessage message="Error occurred" type="error" />,
);
const element = screen.getByText("Error occurred");
const element = screen.getAllByText("Error occurred")[0];
const container = element.closest(
"div.flex.gap-2.items-center.justify-start",
);

View File

@@ -1,23 +1,35 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { PayloadAction } from "@reduxjs/toolkit";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
import ArrowUp from "#/icons/angle-up-solid.svg?react";
import CheckCircle from "#/icons/check-circle-solid.svg?react";
import XCircle from "#/icons/x-circle-solid.svg?react";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import { MonoComponent } from "./mono-component";
import { PathComponent } from "./path-component";
const trimText = (text: string, maxLength: number): string => {
if (!text) return "";
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
interface ExpandableMessageProps {
id?: string;
message: string;
type: string;
success?: boolean;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
}
export function ExpandableMessage({
@@ -25,20 +37,63 @@ export function ExpandableMessage({
message,
type,
success,
observation,
action,
}: ExpandableMessageProps) {
const { data: config } = useConfig();
const { t, i18n } = useTranslation();
const [showDetails, setShowDetails] = useState(true);
const [headline, setHeadline] = useState("");
const [details, setDetails] = useState(message);
const [translationId, setTranslationId] = useState<string | undefined>(id);
const [translationParams, setTranslationParams] = useState<
Record<string, unknown>
>({
observation,
action,
});
useEffect(() => {
if (id && i18n.exists(id)) {
setHeadline(t(id));
let processedObservation = observation;
let processedAction = action;
if (action && action.payload.action === "run") {
const trimmedCommand = trimText(action.payload.args.command, 80);
processedAction = {
...action,
payload: {
...action.payload,
args: {
...action.payload.args,
command: trimmedCommand,
},
},
};
}
if (observation && observation.payload.observation === "run") {
const trimmedCommand = trimText(observation.payload.extras.command, 80);
processedObservation = {
...observation,
payload: {
...observation.payload,
extras: {
...observation.payload.extras,
command: trimmedCommand,
},
},
};
}
setTranslationId(id);
setTranslationParams({
observation: processedObservation,
action: processedAction,
});
setDetails(message);
setShowDetails(false);
}
}, [id, message, i18n.language]);
}, [id, message, observation, action, i18n.language]);
const statusIconClasses = "h-4 w-4 ml-2 inline";
@@ -78,36 +133,44 @@ export function ExpandableMessage({
<div className="flex flex-row justify-between items-center w-full">
<span
className={cn(
headline ? "font-bold" : "",
"font-bold",
type === "error" ? "text-danger" : "text-neutral-300",
)}
>
{headline && (
<>
{headline}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="cursor-pointer text-left"
>
{showDetails ? (
<ArrowUp
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
) : (
<ArrowDown
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
)}
</button>
</>
{translationId && i18n.exists(translationId) ? (
<Trans
i18nKey={translationId}
values={translationParams}
components={{
bold: <strong />,
path: <PathComponent />,
cmd: <MonoComponent />,
}}
/>
) : (
message
)}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="cursor-pointer text-left"
>
{showDetails ? (
<ArrowUp
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
) : (
<ArrowDown
className={cn(
"h-4 w-4 ml-2 inline",
type === "error" ? "fill-danger" : "fill-neutral-300",
)}
/>
)}
</button>
</span>
{type === "action" && success !== undefined && (
<span className="flex-shrink-0">
@@ -125,7 +188,7 @@ export function ExpandableMessage({
</span>
)}
</div>
{(!headline || showDetails) && (
{showDetails && (
<div className="text-sm overflow-auto">
<Markdown
components={{

View File

@@ -26,6 +26,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
id={message.translationID}
message={message.content}
success={message.success}
observation={message.observation}
action={message.action}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>

View File

@@ -0,0 +1,37 @@
import { ReactNode } from "react";
import EventLogger from "#/utils/event-logger";
const decodeHtmlEntities = (text: string): string => {
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
function MonoComponent(props: { children?: ReactNode }) {
const { children } = props;
const decodeString = (str: string): string => {
try {
return decodeHtmlEntities(str);
} catch (e) {
EventLogger.error(String(e));
return str;
}
};
if (Array.isArray(children)) {
const processedChildren = children.map((child) =>
typeof child === "string" ? decodeString(child) : child,
);
return <strong className="font-mono">{processedChildren}</strong>;
}
if (typeof children === "string") {
return <strong className="font-mono">{decodeString(children)}</strong>;
}
return <strong className="font-mono">{children}</strong>;
}
export { MonoComponent };

View File

@@ -0,0 +1,67 @@
import { ReactNode } from "react";
import EventLogger from "#/utils/event-logger";
/**
* Decodes HTML entities in a string
* @param text The text to decode
* @returns The decoded text
*/
const decodeHtmlEntities = (text: string): string => {
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
/**
* Extracts the filename from a path
* @param path The full path
* @returns The filename (last part of the path)
*/
const extractFilename = (path: string): string => {
if (!path) return "";
// Handle both Unix and Windows paths
const parts = path.split(/[/\\]/);
return parts[parts.length - 1];
};
/**
* Component that displays only the filename in the text but shows the full path on hover
* Similar to MonoComponent but with path-specific functionality
*/
function PathComponent(props: { children?: ReactNode }) {
const { children } = props;
const processPath = (path: string) => {
try {
// First decode any HTML entities in the path
const decodedPath = decodeHtmlEntities(path);
// Extract the filename from the decoded path
const filename = extractFilename(decodedPath);
return (
<span className="font-mono" title={decodedPath}>
{filename}
</span>
);
} catch (e) {
// Just log the error without any message to avoid localization issues
EventLogger.error(String(e));
return <span className="font-mono">{path}</span>;
}
};
if (Array.isArray(children)) {
const processedChildren = children.map((child) =>
typeof child === "string" ? processPath(child) : child,
);
return <strong className="font-mono">{processedChildren}</strong>;
}
if (typeof children === "string") {
return <strong>{processPath(children)}</strong>;
}
return <strong className="font-mono">{children}</strong>;
}
export { PathComponent };

View File

@@ -4751,19 +4751,19 @@
"tr": "Çalışma alanını kapat"
},
"ACTION_MESSAGE$RUN": {
"en": "Running a bash command",
"zh-CN": "运行",
"zh-TW": "執行",
"ko-KR": "실행",
"ja": "実行",
"no": "Kjører en bash-kommando",
"ar": "تشغيل أمر باش",
"de": "Führt einen Bash-Befehl aus",
"fr": "Exécution d'une commande bash",
"it": "Esecuzione di un comando bash",
"pt": "Executando um comando bash",
"es": "Ejecutando un comando bash",
"tr": "Bash komutu çalıştırılıyor"
"en": "Running <cmd>{{action.payload.args.command}}</cmd>",
"zh-CN": "运行 <cmd>{{action.payload.args.command}}</cmd>",
"zh-TW": "執行 <cmd>{{action.payload.args.command}}</cmd>",
"ko-KR": "실행 <cmd>{{action.payload.args.command}}</cmd>",
"ja": "実行 <cmd>{{action.payload.args.command}}</cmd>",
"no": "Kjører <cmd>{{action.payload.args.command}}</cmd>",
"ar": "تشغيل <cmd>{{action.payload.args.command}}</cmd>",
"de": "Führt <cmd>{{action.payload.args.command}}</cmd> aus",
"fr": "Exécution de <cmd>{{action.payload.args.command}}</cmd>",
"it": "Esecuzione di <cmd>{{action.payload.args.command}}</cmd>",
"pt": "Executando <cmd>{{action.payload.args.command}}</cmd>",
"es": "Ejecutando <cmd>{{action.payload.args.command}}</cmd>",
"tr": "<cmd>{{action.payload.args.command}}</cmd> çalıştırılıyor"
},
"ACTION_MESSAGE$RUN_IPYTHON": {
"en": "Running a Python command",
@@ -4781,49 +4781,49 @@
"tr": "Python komutu çalıştırılıyor"
},
"ACTION_MESSAGE$READ": {
"en": "Reading the contents of a file",
"zh-CN": "读取",
"zh-TW": "讀取",
"ko-KR": "읽기",
"ja": "読み取り",
"no": "Leser innholdet i en fil",
"ar": "قراءة محتويات ملف",
"de": "Liest den Inhalt einer Datei",
"fr": "Lecture du contenu d'un fichier",
"it": "Lettura del contenuto di un file",
"pt": "Lendo o conteúdo de um arquivo",
"es": "Leyendo el contenido de un archivo",
"tr": "Dosya içeriği okunuyor"
"en": "Reading <path>{{action.payload.args.path}}</path>",
"zh-CN": "读取 <path>{{action.payload.args.path}}</path>",
"zh-TW": "讀取 <path>{{action.payload.args.path}}</path>",
"ko-KR": "읽기 <path>{{action.payload.args.path}}</path>",
"ja": "読み取り <path>{{action.payload.args.path}}</path>",
"no": "Leser <path>{{action.payload.args.path}}</path>",
"ar": "قراءة <path>{{action.payload.args.path}}</path>",
"de": "Liest <path>{{action.payload.args.path}}</path>",
"fr": "Lecture de <path>{{action.payload.args.path}}</path>",
"it": "Lettura di <path>{{action.payload.args.path}}</path>",
"pt": "Lendo <path>{{action.payload.args.path}}</path>",
"es": "Leyendo <path>{{action.payload.args.path}}</path>",
"tr": "<path>{{action.payload.args.path}}</path> okunuyor"
},
"ACTION_MESSAGE$EDIT": {
"en": "Editing the contents of a file",
"zh-CN": "编辑",
"zh-TW": "編輯",
"ko-KR": "편집",
"ja": "編集",
"no": "Redigerer innholdet i en fil",
"ar": "تحرير محتويات ملف",
"de": "Bearbeitet den Inhalt einer Datei",
"fr": "Modification du contenu d'un fichier",
"it": "Modifica del contenuto di un file",
"pt": "Editando o conteúdo de um arquivo",
"es": "Editando el contenido de un archivo",
"tr": "Dosya içeriği düzenleniyor"
"en": "Editing <path>{{action.payload.args.path}}</path>",
"zh-CN": "编辑 <path>{{action.payload.args.path}}</path>",
"zh-TW": "編輯 <path>{{action.payload.args.path}}</path>",
"ko-KR": "편집 <path>{{action.payload.args.path}}</path>",
"ja": "編集 <path>{{action.payload.args.path}}</path>",
"no": "Redigerer <path>{{action.payload.args.path}}</path>",
"ar": "تحرير <path>{{action.payload.args.path}}</path>",
"de": "Bearbeitet <path>{{action.payload.args.path}}</path>",
"fr": "Modification de <path>{{action.payload.args.path}}</path>",
"it": "Modifica di <path>{{action.payload.args.path}}</path>",
"pt": "Editando <path>{{action.payload.args.path}}</path>",
"es": "Editando <path>{{action.payload.args.path}}</path>",
"tr": "<path>{{action.payload.args.path}}</path> düzenleniyor"
},
"ACTION_MESSAGE$WRITE": {
"en": "Writing to a file",
"zh-CN": "写入",
"zh-TW": "寫入",
"ko-KR": "쓰기",
"ja": "書き込み",
"no": "Skriver til en fil",
"ar": "الكتابة إلى ملف",
"de": "Schreibt in eine Datei",
"fr": "Écriture dans un fichier",
"it": "Scrittura su file",
"pt": "Escrevendo em um arquivo",
"es": "Escribiendo en un archivo",
"tr": "Dosyaya yazılıyor"
"en": "Writing to <path>{{action.payload.args.path}}</path>",
"zh-CN": "写入 <path>{{action.payload.args.path}}</path>",
"zh-TW": "寫入 <path>{{action.payload.args.path}}</path>",
"ko-KR": "쓰기 <path>{{action.payload.args.path}}</path>",
"ja": "書き込み <path>{{action.payload.args.path}}</path>",
"no": "Skriver til <path>{{action.payload.args.path}}</path>",
"ar": "الكتابة إلى <path>{{action.payload.args.path}}</path>",
"de": "Schreibt in <path>{{action.payload.args.path}}</path>",
"fr": "Écriture dans <path>{{action.payload.args.path}}</path>",
"it": "Scrittura su <path>{{action.payload.args.path}}</path>",
"pt": "Escrevendo em <path>{{action.payload.args.path}}</path>",
"es": "Escribiendo en <path>{{action.payload.args.path}}</path>",
"tr": "<path>{{action.payload.args.path}}</path> dosyasına yazılıyor"
},
"ACTION_MESSAGE$BROWSE": {
"en": "Browsing the web",
@@ -4871,19 +4871,19 @@
"tr": "Düşünüyor"
},
"OBSERVATION_MESSAGE$RUN": {
"en": "Ran a bash command",
"zh-CN": "运行",
"zh-TW": "執行",
"ko-KR": "실행",
"ja": "実行",
"no": "Kjørte en bash-kommando",
"ar": "تم تشغيل أمر باش",
"de": "Führte einen Bash-Befehl aus",
"fr": "A exécuté une commande bash",
"it": "Ha eseguito un comando bash",
"pt": "Executou um comando bash",
"es": "Ejecutó un comando bash",
"tr": "Bash komutu çalıştırıldı"
"en": "Ran <cmd>{{observation.payload.extras.command}}</cmd>",
"zh-CN": "运行 <cmd>{{observation.payload.extras.command}}</cmd>",
"zh-TW": "執行 <cmd>{{observation.payload.extras.command}}</cmd>",
"ko-KR": "실행 <cmd>{{observation.payload.extras.command}}</cmd>",
"ja": "実行 <cmd>{{observation.payload.extras.command}}</cmd>",
"no": "Kjørte <cmd>{{observation.payload.extras.command}}</cmd>",
"ar": "تم تشغيل <cmd>{{observation.payload.extras.command}}</cmd>",
"de": "Führte <cmd>{{observation.payload.extras.command}}</cmd> aus",
"fr": "A exécuté <cmd>{{observation.payload.extras.command}}</cmd>",
"it": "Ha eseguito <cmd>{{observation.payload.extras.command}}</cmd>",
"pt": "Executou <cmd>{{observation.payload.extras.command}}</cmd>",
"es": "Ejecutó <cmd>{{observation.payload.extras.command}}</cmd>",
"tr": "<cmd>{{observation.payload.extras.command}}</cmd> çalıştırıldı"
},
"OBSERVATION_MESSAGE$RUN_IPYTHON": {
"en": "Ran a Python command",
@@ -4901,49 +4901,49 @@
"tr": "Python komutu çalıştırıldı"
},
"OBSERVATION_MESSAGE$READ": {
"en": "Read the contents of a file",
"zh-CN": "读取",
"zh-TW": "讀取",
"ko-KR": "읽기",
"ja": "読み取り",
"no": "Leste innholdet i en fil",
"ar": "تمت قراءة محتويات ملف",
"de": "Las den Inhalt einer Datei",
"fr": "A lu le contenu d'un fichier",
"it": "Ha letto il contenuto di un file",
"pt": "Leu o conteúdo de um arquivo",
"es": "Leyó el contenido de un archivo",
"tr": "Dosya içeriği okundu"
"en": "Read <path>{{observation.payload.extras.path}}</path>",
"zh-CN": "读取 <path>{{observation.payload.extras.path}}</path>",
"zh-TW": "讀取 <path>{{observation.payload.extras.path}}</path>",
"ko-KR": "읽기 <path>{{observation.payload.extras.path}}</path>",
"ja": "読み取り <path>{{observation.payload.extras.path}}</path>",
"no": "Leste <path>{{observation.payload.extras.path}}</path>",
"ar": "تمت قراءة <path>{{observation.payload.extras.path}}</path>",
"de": "Las <path>{{observation.payload.extras.path}}</path>",
"fr": "A lu <path>{{observation.payload.extras.path}}</path>",
"it": "Ha letto <path>{{observation.payload.extras.path}}</path>",
"pt": "Leu <path>{{observation.payload.extras.path}}</path>",
"es": "Leyó <path>{{observation.payload.extras.path}}</path>",
"tr": "<path>{{observation.payload.extras.path}}</path> okundu"
},
"OBSERVATION_MESSAGE$EDIT": {
"en": "Edited the contents of a file",
"zh-CN": "编辑",
"zh-TW": "編輯",
"ko-KR": "편집",
"ja": "編集",
"no": "Redigerte innholdet i en fil",
"ar": "تم تحرير محتويات ملف",
"de": "Hat den Inhalt einer Datei bearbeitet",
"fr": "A modifié le contenu d'un fichier",
"it": "Ha modificato il contenuto di un file",
"pt": "Editou o conteúdo de um arquivo",
"es": "Editó el contenido de un archivo",
"tr": "Dosya içeriği düzenlendi"
"en": "Edited <path>{{observation.payload.extras.path}}</path>",
"zh-CN": "编辑 <path>{{observation.payload.extras.path}}</path>",
"zh-TW": "編輯 <path>{{observation.payload.extras.path}}</path>",
"ko-KR": "편집 <path>{{observation.payload.extras.path}}</path>",
"ja": "編集 <path>{{observation.payload.extras.path}}</path>",
"no": "Redigerte <path>{{observation.payload.extras.path}}</path>",
"ar": "تم تحرير <path>{{observation.payload.extras.path}}</path>",
"de": "Hat <path>{{observation.payload.extras.path}}</path> bearbeitet",
"fr": "A modifié <path>{{observation.payload.extras.path}}</path>",
"it": "Ha modificato <path>{{observation.payload.extras.path}}</path>",
"pt": "Editou <path>{{observation.payload.extras.path}}</path>",
"es": "Editó <path>{{observation.payload.extras.path}}</path>",
"tr": "<path>{{observation.payload.extras.path}}</path> düzenlendi"
},
"OBSERVATION_MESSAGE$WRITE": {
"en": "Wrote to a file",
"zh-CN": "写入",
"zh-TW": "寫入",
"ko-KR": "쓰기",
"ja": "書き込み",
"no": "Skrev til en fil",
"ar": "تمت الكتابة إلى ملف",
"de": "Hat in eine Datei geschrieben",
"fr": "A écrit dans un fichier",
"it": "Ha scritto su un file",
"pt": "Escreveu em um arquivo",
"es": "Escribió en un archivo",
"tr": "Dosyaya yazıldı"
"en": "Wrote to <path>{{observation.payload.extras.path}}</path>",
"zh-CN": "写入 <path>{{observation.payload.extras.path}}</path>",
"zh-TW": "寫入 <path>{{observation.payload.extras.path}}</path>",
"ko-KR": "쓰기 <path>{{observation.payload.extras.path}}</path>",
"ja": "書き込み <path>{{observation.payload.extras.path}}</path>",
"no": "Skrev til <path>{{observation.payload.extras.path}}</path>",
"ar": "تمت الكتابة إلى <path>{{observation.payload.extras.path}}</path>",
"de": "Hat in <path>{{observation.payload.extras.path}}</path> geschrieben",
"fr": "A écrit dans <path>{{observation.payload.extras.path}}</path>",
"it": "Ha scritto su <path>{{observation.payload.extras.path}}</path>",
"pt": "Escreveu em <path>{{observation.payload.extras.path}}</path>",
"es": "Escribió en <path>{{observation.payload.extras.path}}</path>",
"tr": "<path>{{observation.payload.extras.path}}</path> dosyasına yazıldı"
},
"OBSERVATION_MESSAGE$BROWSE": {
"en": "Browsing completed",

View File

@@ -1,3 +1,7 @@
import { PayloadAction } from "@reduxjs/toolkit";
import { OpenHandsObservation } from "./types/core/observations";
import { OpenHandsAction } from "./types/core/actions";
export type Message = {
sender: "user" | "assistant";
content: string;
@@ -8,4 +12,6 @@ export type Message = {
pending?: boolean;
translationID?: string;
eventID?: number;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
};

View File

@@ -2,14 +2,14 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { Message } from "#/message";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import {
OpenHandsObservation,
CommandObservation,
IPythonObservation,
RecallObservation,
} from "#/types/core/observations";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsEventType } from "#/types/core/base";
import {
CommandObservation,
IPythonObservation,
OpenHandsObservation,
RecallObservation,
} from "#/types/core/observations";
type SliceState = { messages: Message[] };
@@ -135,6 +135,7 @@ export const chatSlice = createSlice({
content: text,
imageUrls: [],
timestamp: new Date().toISOString(),
action,
};
state.messages.push(message);
@@ -224,6 +225,7 @@ export const chatSlice = createSlice({
return;
}
causeMessage.translationID = translationID;
causeMessage.observation = observation;
// Set success property based on observation type
if (observationID === "run") {
const commandObs = observation.payload as CommandObservation;
@@ -253,9 +255,7 @@ export const chatSlice = createSlice({
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
content = `${
causeMessage.content
}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
content = `${causeMessage.content}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
causeMessage.content = content; // Observation content includes the action
} else if (observationID === "read") {
causeMessage.content = `\`\`\`\n${observation.payload.content}\n\`\`\``; // Content is already truncated by the ACI