refactor(frontend): system message modal (#10969)

This commit is contained in:
Hiep Le
2025-09-17 21:56:14 +07:00
committed by GitHub
parent ac9badbd20
commit 910177fc57
12 changed files with 367 additions and 168 deletions

View File

@@ -1,12 +1,9 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import ReactJsonView from "@microlink/react-json-view";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { useState } from "react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { cn } from "#/utils/utils";
import { JSON_VIEW_THEME } from "#/utils/constants";
import { SystemMessageHeader } from "./system-message-modal/system-message-header";
import { TabNavigation } from "./system-message-modal/tab-navigation";
import { TabContent } from "./system-message-modal/tab-content";
interface SystemMessageModalProps {
isOpen: boolean;
@@ -19,26 +16,11 @@ interface SystemMessageModalProps {
} | null;
}
interface FunctionData {
name?: string;
description?: string;
parameters?: Record<string, unknown>;
}
interface ToolData {
type?: string;
function?: FunctionData;
name?: string;
description?: string;
parameters?: Record<string, unknown>;
}
export function SystemMessageModal({
isOpen,
onClose,
systemMessage,
}: SystemMessageModalProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"system" | "tools">("system");
const [expandedTools, setExpandedTools] = useState<Record<number, boolean>>(
{},
@@ -62,155 +44,27 @@ export function SystemMessageModal({
width="medium"
className="max-h-[80vh] flex flex-col items-start"
>
<div className="flex flex-col gap-6 w-full">
<BaseModalTitle title={t("SYSTEM_MESSAGE_MODAL$TITLE")} />
<div className="flex flex-col gap-2">
{systemMessage.agent_class && (
<div className="text-sm">
<span className="font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$AGENT_CLASS")}
</span>{" "}
<span className="font-medium text-gray-100">
{systemMessage.agent_class}
</span>
</div>
)}
{systemMessage.openhands_version && (
<div className="text-sm">
<span className="font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$OPENHANDS_VERSION")}
</span>{" "}
<span className="text-gray-100">
{systemMessage.openhands_version}
</span>
</div>
)}
</div>
</div>
<SystemMessageHeader
agentClass={systemMessage.agent_class}
openhandsVersion={systemMessage.openhands_version}
/>
<div className="w-full">
<div className="flex border-b mb-2">
<button
type="button"
className={cn(
"px-4 py-2 font-medium border-b-2 transition-colors",
activeTab === "system"
? "border-primary text-gray-100"
: "border-transparent hover:text-gray-700 dark:hover:text-gray-300",
)}
onClick={() => setActiveTab("system")}
>
{t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")}
</button>
{systemMessage.tools && systemMessage.tools.length > 0 && (
<button
type="button"
className={cn(
"px-4 py-2 font-medium border-b-2 transition-colors",
activeTab === "tools"
? "border-primary text-gray-100"
: "border-transparent hover:text-gray-700 dark:hover:text-gray-300",
)}
onClick={() => setActiveTab("tools")}
>
{t("SYSTEM_MESSAGE_MODAL$TOOLS_TAB")}
</button>
)}
</div>
<TabNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
hasTools={
!!(systemMessage.tools && systemMessage.tools.length > 0)
}
/>
<div className="max-h-[51vh] overflow-auto rounded-md">
{activeTab === "system" && (
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
{systemMessage.content}
</div>
)}
{activeTab === "tools" &&
systemMessage.tools &&
systemMessage.tools.length > 0 && (
<div className="p-2 space-y-3">
{systemMessage.tools.map((tool, index) => {
// Extract function data from the nested structure
const toolData = tool as ToolData;
const functionData = toolData.function || toolData;
const name =
functionData.name ||
(toolData.type === "function" &&
toolData.function?.name) ||
"";
const description =
functionData.description ||
(toolData.type === "function" &&
toolData.function?.description) ||
"";
const parameters =
functionData.parameters ||
(toolData.type === "function" &&
toolData.function?.parameters) ||
null;
const isExpanded = expandedTools[index] || false;
return (
<div key={index} className="rounded-md overflow-hidden">
<button
type="button"
onClick={() => toggleTool(index)}
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<h3 className="font-bold text-gray-100">
{String(name)}
</h3>
</div>
<span className="text-gray-300">
{isExpanded ? (
<ChevronDown size={18} />
) : (
<ChevronRight size={18} />
)}
</span>
</button>
{isExpanded && (
<div className="px-2 pb-3 pt-1">
<div className="mt-2 mb-3">
<p className="text-sm whitespace-pre-wrap text-gray-300 leading-relaxed">
{String(description)}
</p>
</div>
{/* Parameters section */}
{parameters && (
<div className="mt-2">
<h4 className="text-sm font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$PARAMETERS")}
</h4>
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<ReactJsonView
name={false}
src={parameters}
theme={JSON_VIEW_THEME}
/>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{activeTab === "tools" &&
(!systemMessage.tools || systemMessage.tools.length === 0) && (
<div className="flex items-center justify-center h-full p-4">
<p className="text-gray-400">
{t("SYSTEM_MESSAGE_MODAL$NO_TOOLS")}
</p>
</div>
)}
<div className="max-h-[51vh] overflow-auto rounded-md custom-scrollbar-always">
<TabContent
activeTab={activeTab}
systemMessage={systemMessage}
expandedTools={expandedTools}
onToggleTool={toggleTool}
/>
</div>
</div>
</ModalBody>

View File

@@ -0,0 +1,14 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
export function EmptyToolsState() {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full p-4">
<Typography.Text className="text-gray-400">
{t("SYSTEM_MESSAGE_MODAL$NO_TOOLS")}
</Typography.Text>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Typography } from "#/ui/typography";
interface SystemMessageContentProps {
content: string;
}
export function SystemMessageContent({ content }: SystemMessageContentProps) {
return (
<div className="p-4 shadow-inner">
<Typography.CodeBlock>{content}</Typography.CodeBlock>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { Typography } from "#/ui/typography";
interface SystemMessageHeaderProps {
agentClass: string | null;
openhandsVersion: string | null;
}
export function SystemMessageHeader({
agentClass,
openhandsVersion,
}: SystemMessageHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 w-full">
<BaseModalTitle title={t("SYSTEM_MESSAGE_MODAL$TITLE")} />
<div className="flex flex-col gap-2">
{agentClass && (
<div className="text-sm">
<Typography.Text className="font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$AGENT_CLASS")}
</Typography.Text>{" "}
<Typography.Text className="font-medium text-gray-100">
{agentClass}
</Typography.Text>
</div>
)}
{openhandsVersion && (
<div className="text-sm">
<Typography.Text className="font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$OPENHANDS_VERSION")}
</Typography.Text>{" "}
<Typography.Text className="text-gray-100">
{openhandsVersion}
</Typography.Text>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { cn } from "#/utils/utils";
interface TabButtonProps {
isActive: boolean;
children: React.ReactNode;
onClick: () => void;
className?: string;
disabled?: boolean;
}
export function TabButton({
isActive,
children,
onClick,
className,
disabled = false,
}: TabButtonProps) {
return (
<button
type="button"
disabled={disabled}
className={cn(
"px-4 py-2 font-medium border-b-2 transition-colors",
isActive
? "border-primary text-gray-100"
: "border-transparent hover:text-gray-700 dark:hover:text-gray-300",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
onClick={onClick}
aria-selected={isActive}
role="tab"
>
{children}
</button>
);
}

View File

@@ -0,0 +1,40 @@
import { SystemMessageContent } from "./system-message-content";
import { ToolsList } from "./tools-list";
import { EmptyToolsState } from "./empty-tools-state";
interface TabContentProps {
activeTab: "system" | "tools";
systemMessage: {
content: string;
tools: Array<Record<string, unknown>> | null;
};
expandedTools: Record<number, boolean>;
onToggleTool: (index: number) => void;
}
export function TabContent({
activeTab,
systemMessage,
expandedTools,
onToggleTool,
}: TabContentProps) {
if (activeTab === "system") {
return <SystemMessageContent content={systemMessage.content} />;
}
if (activeTab === "tools") {
if (systemMessage.tools && systemMessage.tools.length > 0) {
return (
<ToolsList
tools={systemMessage.tools}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
/>
);
}
return <EmptyToolsState />;
}
return null;
}

View File

@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { TabButton } from "./tab-button";
interface TabNavigationProps {
activeTab: "system" | "tools";
onTabChange: (tab: "system" | "tools") => void;
hasTools: boolean;
}
export function TabNavigation({
activeTab,
onTabChange,
hasTools,
}: TabNavigationProps) {
const { t } = useTranslation();
return (
<div className="flex border-b mb-2" role="tablist">
<TabButton
isActive={activeTab === "system"}
onClick={() => onTabChange("system")}
>
{t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")}
</TabButton>
{hasTools && (
<TabButton
isActive={activeTab === "tools"}
onClick={() => onTabChange("tools")}
>
{t("SYSTEM_MESSAGE_MODAL$TOOLS_TAB")}
</TabButton>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { Typography } from "#/ui/typography";
interface ToggleButtonProps {
title: string;
isExpanded: boolean;
onClick: () => void;
className?: string;
}
export function ToggleButton({
title,
isExpanded,
onClick,
className,
}: ToggleButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={`w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors ${className || ""}`}
>
<div className="flex items-center">
<Typography.Text className="font-bold text-gray-100">
{title}
</Typography.Text>
</div>
<Typography.Text className="text-gray-300">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</Typography.Text>
</button>
);
}

View File

@@ -0,0 +1,65 @@
import { Typography } from "#/ui/typography";
import { ToolParameters } from "./tool-parameters";
import { ToggleButton } from "./toggle-button";
interface FunctionData {
name?: string;
description?: string;
parameters?: Record<string, unknown>;
}
interface ToolData {
type?: string;
function?: FunctionData;
name?: string;
description?: string;
parameters?: Record<string, unknown>;
}
interface ToolItemProps {
tool: Record<string, unknown>;
index: number;
isExpanded: boolean;
onToggle: (index: number) => void;
}
export function ToolItem({ tool, index, isExpanded, onToggle }: ToolItemProps) {
// Extract function data from the nested structure
const toolData = tool as ToolData;
const functionData = toolData.function || toolData;
const name =
functionData.name ||
(toolData.type === "function" && toolData.function?.name) ||
"";
const description =
functionData.description ||
(toolData.type === "function" && toolData.function?.description) ||
"";
const parameters =
functionData.parameters ||
(toolData.type === "function" && toolData.function?.parameters) ||
null;
return (
<div className="rounded-md overflow-hidden">
<ToggleButton
title={String(name)}
isExpanded={isExpanded}
onClick={() => onToggle(index)}
/>
{isExpanded && (
<div className="px-2 pb-3 pt-1">
<div className="mt-2 mb-3">
<Typography.Text className="text-sm whitespace-pre-wrap text-gray-300 leading-relaxed">
{String(description)}
</Typography.Text>
</div>
{/* Parameters section */}
{parameters && <ToolParameters parameters={parameters} />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import ReactJsonView from "@microlink/react-json-view";
import { JSON_VIEW_THEME } from "#/utils/constants";
import { Typography } from "#/ui/typography";
interface ToolParametersProps {
parameters: Record<string, unknown>;
}
export function ToolParameters({ parameters }: ToolParametersProps) {
const { t } = useTranslation();
return (
<div className="mt-2">
<Typography.Text className="text-sm font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$PARAMETERS")}
</Typography.Text>
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<ReactJsonView name={false} src={parameters} theme={JSON_VIEW_THEME} />
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { ToolItem } from "./tool-item";
interface ToolsListProps {
tools: Array<Record<string, unknown>>;
expandedTools: Record<number, boolean>;
onToggleTool: (index: number) => void;
}
export function ToolsList({
tools,
expandedTools,
onToggleTool,
}: ToolsListProps) {
return (
<div className="p-2 space-y-3">
{tools.map((tool, index) => (
<ToolItem
key={index}
tool={tool}
index={index}
isExpanded={expandedTools[index] || false}
onToggle={onToggleTool}
/>
))}
</div>
);
}

View File

@@ -6,6 +6,8 @@ const typographyVariants = cva("", {
variant: {
h1: "text-[32px] text-white font-bold leading-5",
span: "text-sm font-normal text-white leading-5.5",
codeBlock:
"font-mono text-sm leading-relaxed text-gray-300 whitespace-pre-wrap",
},
},
defaultVariants: {
@@ -62,6 +64,19 @@ export function Text({
);
}
export function CodeBlock({
className,
testId,
children,
}: Omit<TypographyProps, "variant">) {
return (
<Typography variant="codeBlock" className={className} testId={testId}>
{children}
</Typography>
);
}
// Attach components to Typography for the expected API
Typography.H1 = H1;
Typography.Text = Text;
Typography.CodeBlock = CodeBlock;