From dd03d9adcee429c9260484ece69db74f1e2c0537 Mon Sep 17 00:00:00 2001 From: Carlos Freund Date: Tue, 8 Apr 2025 14:44:42 +0200 Subject: [PATCH] feat(frontend):Display path of file ops and cmd in headline (#7530) Co-authored-by: openhands Co-authored-by: Carlos Freund --- .../chat/expandable-message.test.tsx | 4 +- .../features/chat/expandable-message.tsx | 137 ++++++++---- .../src/components/features/chat/messages.tsx | 2 + .../features/chat/mono-component.tsx | 37 ++++ .../features/chat/path-component.tsx | 67 ++++++ frontend/src/i18n/translation.json | 208 +++++++++--------- frontend/src/message.d.ts | 6 + frontend/src/state/chat-slice.ts | 18 +- 8 files changed, 327 insertions(+), 152 deletions(-) create mode 100644 frontend/src/components/features/chat/mono-component.tsx create mode 100644 frontend/src/components/features/chat/path-component.tsx diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 965d41da83..3e4f2c3d39 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -23,7 +23,7 @@ vi.mock("react-i18next", async () => { describe("ExpandableMessage", () => { it("should render with neutral border for non-action messages", () => { renderWithProviders(); - 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( , ); - 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", ); diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index 49c1fee35f..3d553ca957 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -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; + action?: PayloadAction; } 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(id); + const [translationParams, setTranslationParams] = useState< + Record + >({ + 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({
- {headline && ( - <> - {headline} - - + {translationId && i18n.exists(translationId) ? ( + , + path: , + cmd: , + }} + /> + ) : ( + message )} + {type === "action" && success !== undefined && ( @@ -125,7 +188,7 @@ export function ExpandableMessage({ )}
- {(!headline || showDetails) && ( + {showDetails && (
= React.memo( id={message.translationID} message={message.content} success={message.success} + observation={message.observation} + action={message.action} /> {shouldShowConfirmationButtons && }
diff --git a/frontend/src/components/features/chat/mono-component.tsx b/frontend/src/components/features/chat/mono-component.tsx new file mode 100644 index 0000000000..a279e85548 --- /dev/null +++ b/frontend/src/components/features/chat/mono-component.tsx @@ -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 {processedChildren}; + } + + if (typeof children === "string") { + return {decodeString(children)}; + } + + return {children}; +} + +export { MonoComponent }; diff --git a/frontend/src/components/features/chat/path-component.tsx b/frontend/src/components/features/chat/path-component.tsx new file mode 100644 index 0000000000..ad341b5769 --- /dev/null +++ b/frontend/src/components/features/chat/path-component.tsx @@ -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 ( + + {filename} + + ); + } catch (e) { + // Just log the error without any message to avoid localization issues + EventLogger.error(String(e)); + return {path}; + } + }; + + if (Array.isArray(children)) { + const processedChildren = children.map((child) => + typeof child === "string" ? processPath(child) : child, + ); + + return {processedChildren}; + } + + if (typeof children === "string") { + return {processPath(children)}; + } + + return {children}; +} + +export { PathComponent }; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 3bc996d85a..60e9eb96d8 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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 {{action.payload.args.command}}", + "zh-CN": "运行 {{action.payload.args.command}}", + "zh-TW": "執行 {{action.payload.args.command}}", + "ko-KR": "실행 {{action.payload.args.command}}", + "ja": "実行 {{action.payload.args.command}}", + "no": "Kjører {{action.payload.args.command}}", + "ar": "تشغيل {{action.payload.args.command}}", + "de": "Führt {{action.payload.args.command}} aus", + "fr": "Exécution de {{action.payload.args.command}}", + "it": "Esecuzione di {{action.payload.args.command}}", + "pt": "Executando {{action.payload.args.command}}", + "es": "Ejecutando {{action.payload.args.command}}", + "tr": "{{action.payload.args.command}} ç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 {{action.payload.args.path}}", + "zh-CN": "读取 {{action.payload.args.path}}", + "zh-TW": "讀取 {{action.payload.args.path}}", + "ko-KR": "읽기 {{action.payload.args.path}}", + "ja": "読み取り {{action.payload.args.path}}", + "no": "Leser {{action.payload.args.path}}", + "ar": "قراءة {{action.payload.args.path}}", + "de": "Liest {{action.payload.args.path}}", + "fr": "Lecture de {{action.payload.args.path}}", + "it": "Lettura di {{action.payload.args.path}}", + "pt": "Lendo {{action.payload.args.path}}", + "es": "Leyendo {{action.payload.args.path}}", + "tr": "{{action.payload.args.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 {{action.payload.args.path}}", + "zh-CN": "编辑 {{action.payload.args.path}}", + "zh-TW": "編輯 {{action.payload.args.path}}", + "ko-KR": "편집 {{action.payload.args.path}}", + "ja": "編集 {{action.payload.args.path}}", + "no": "Redigerer {{action.payload.args.path}}", + "ar": "تحرير {{action.payload.args.path}}", + "de": "Bearbeitet {{action.payload.args.path}}", + "fr": "Modification de {{action.payload.args.path}}", + "it": "Modifica di {{action.payload.args.path}}", + "pt": "Editando {{action.payload.args.path}}", + "es": "Editando {{action.payload.args.path}}", + "tr": "{{action.payload.args.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 {{action.payload.args.path}}", + "zh-CN": "写入 {{action.payload.args.path}}", + "zh-TW": "寫入 {{action.payload.args.path}}", + "ko-KR": "쓰기 {{action.payload.args.path}}", + "ja": "書き込み {{action.payload.args.path}}", + "no": "Skriver til {{action.payload.args.path}}", + "ar": "الكتابة إلى {{action.payload.args.path}}", + "de": "Schreibt in {{action.payload.args.path}}", + "fr": "Écriture dans {{action.payload.args.path}}", + "it": "Scrittura su {{action.payload.args.path}}", + "pt": "Escrevendo em {{action.payload.args.path}}", + "es": "Escribiendo en {{action.payload.args.path}}", + "tr": "{{action.payload.args.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 {{observation.payload.extras.command}}", + "zh-CN": "运行 {{observation.payload.extras.command}}", + "zh-TW": "執行 {{observation.payload.extras.command}}", + "ko-KR": "실행 {{observation.payload.extras.command}}", + "ja": "実行 {{observation.payload.extras.command}}", + "no": "Kjørte {{observation.payload.extras.command}}", + "ar": "تم تشغيل {{observation.payload.extras.command}}", + "de": "Führte {{observation.payload.extras.command}} aus", + "fr": "A exécuté {{observation.payload.extras.command}}", + "it": "Ha eseguito {{observation.payload.extras.command}}", + "pt": "Executou {{observation.payload.extras.command}}", + "es": "Ejecutó {{observation.payload.extras.command}}", + "tr": "{{observation.payload.extras.command}} ç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 {{observation.payload.extras.path}}", + "zh-CN": "读取 {{observation.payload.extras.path}}", + "zh-TW": "讀取 {{observation.payload.extras.path}}", + "ko-KR": "읽기 {{observation.payload.extras.path}}", + "ja": "読み取り {{observation.payload.extras.path}}", + "no": "Leste {{observation.payload.extras.path}}", + "ar": "تمت قراءة {{observation.payload.extras.path}}", + "de": "Las {{observation.payload.extras.path}}", + "fr": "A lu {{observation.payload.extras.path}}", + "it": "Ha letto {{observation.payload.extras.path}}", + "pt": "Leu {{observation.payload.extras.path}}", + "es": "Leyó {{observation.payload.extras.path}}", + "tr": "{{observation.payload.extras.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 {{observation.payload.extras.path}}", + "zh-CN": "编辑 {{observation.payload.extras.path}}", + "zh-TW": "編輯 {{observation.payload.extras.path}}", + "ko-KR": "편집 {{observation.payload.extras.path}}", + "ja": "編集 {{observation.payload.extras.path}}", + "no": "Redigerte {{observation.payload.extras.path}}", + "ar": "تم تحرير {{observation.payload.extras.path}}", + "de": "Hat {{observation.payload.extras.path}} bearbeitet", + "fr": "A modifié {{observation.payload.extras.path}}", + "it": "Ha modificato {{observation.payload.extras.path}}", + "pt": "Editou {{observation.payload.extras.path}}", + "es": "Editó {{observation.payload.extras.path}}", + "tr": "{{observation.payload.extras.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 {{observation.payload.extras.path}}", + "zh-CN": "写入 {{observation.payload.extras.path}}", + "zh-TW": "寫入 {{observation.payload.extras.path}}", + "ko-KR": "쓰기 {{observation.payload.extras.path}}", + "ja": "書き込み {{observation.payload.extras.path}}", + "no": "Skrev til {{observation.payload.extras.path}}", + "ar": "تمت الكتابة إلى {{observation.payload.extras.path}}", + "de": "Hat in {{observation.payload.extras.path}} geschrieben", + "fr": "A écrit dans {{observation.payload.extras.path}}", + "it": "Ha scritto su {{observation.payload.extras.path}}", + "pt": "Escreveu em {{observation.payload.extras.path}}", + "es": "Escribió en {{observation.payload.extras.path}}", + "tr": "{{observation.payload.extras.path}} dosyasına yazıldı" }, "OBSERVATION_MESSAGE$BROWSE": { "en": "Browsing completed", diff --git a/frontend/src/message.d.ts b/frontend/src/message.d.ts index 9af06a7384..080e6350d9 100644 --- a/frontend/src/message.d.ts +++ b/frontend/src/message.d.ts @@ -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; + action?: PayloadAction; }; diff --git a/frontend/src/state/chat-slice.ts b/frontend/src/state/chat-slice.ts index 0eac41fc6d..023e75f6bc 100644 --- a/frontend/src/state/chat-slice.ts +++ b/frontend/src/state/chat-slice.ts @@ -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