Repository: comet-ml/opik
Pull request #2149: [OPIK-1542] Add prompt selector to the Playground
Author: login: andriidudar
Created: 2025-05-16T09:38:08Z | Merged: 2025-05-20T07:23:27Z

## Details
![image](https://github.com/user-attachments/assets/c114d8ac-7894-4a60-90f6-e1a61365ed98)
![image](https://github.com/user-attachments/assets/f35e1d83-7bce-458c-b0a3-9cae368e5134)
![image](https://github.com/user-attachments/assets/d4206894-a88e-464c-8628-e88eb35ba1a5)


## Issues

Resolves #

## Testing

## Documentation



Diff:
diff --git a/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts b/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
index 5bd3b2f4608..c4b4364d252 100644
--- a/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
+++ b/apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts
@@ -117,6 +117,7 @@ const getExperimentFromRun = (run: LogQueueParams): LogExperiment => {
     metadata: {
       model: run.model,
       messages: JSON.stringify(run.providerMessages),
+      model_config: run.configs,
     },
   };
 };
diff --git a/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts b/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts
index 44a0ee59c83..57367ec89a9 100644
--- a/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts
+++ b/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts
@@ -11,7 +11,7 @@ type UseCreatePromptVersionMutationParams = {
   template: string;
   metadata?: object;
   changeDescription?: string;
-  onSetActiveVersionId: (versionId: string) => void;
+  onSuccess: (promptVersion: PromptVersion) => void;
 };
 
 const useCreatePromptVersionMutation = () => {
@@ -49,8 +49,8 @@ const useCreatePromptVersionMutation = () => {
         variant: "destructive",
       });
     },
-    onSuccess: async (data: PromptVersion, { onSetActiveVersionId }) => {
-      onSetActiveVersionId(data.id);
+    onSuccess: async (data: PromptVersion, { onSuccess }) => {
+      onSuccess(data);
     },
     onSettled: () => {
       queryClient.invalidateQueries({ queryKey: ["prompt-versions"] });
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog.tsx b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog.tsx
new file mode 100644
index 00000000000..2631b016463
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog.tsx
@@ -0,0 +1,264 @@
+import React, { useEffect, useMemo, useState } from "react";
+import CodeMirror from "@uiw/react-codemirror";
+import { jsonLanguage } from "@codemirror/lang-json";
+import { EditorView } from "@codemirror/view";
+import { SquareArrowOutUpRight } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+
+import useAppStore from "@/store/AppStore";
+import {
+  Dialog,
+  DialogAutoScrollBody,
+  DialogClose,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertTitle } from "@/components/ui/alert";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "@/components/ui/accordion";
+import useCreatePromptVersionMutation from "@/api/prompts/useCreatePromptVersionMutation";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import { useBooleanTimeoutState } from "@/hooks/useBooleanTimeoutState";
+import { useCodemirrorTheme } from "@/hooks/useCodemirrorTheme";
+import { isValidJsonObject, safelyParseJSON } from "@/lib/utils";
+import { PromptWithLatestVersion } from "@/types/prompts";
+import usePromptById from "@/api/prompts/usePromptById";
+import usePromptCreateMutation from "@/api/prompts/usePromptCreateMutation";
+
+const extractMetadata = (prompt?: PromptWithLatestVersion) => {
+  return prompt?.latest_version?.metadata
+    ? JSON.stringify(prompt.latest_version.metadata, null, 2)
+    : "";
+};
+
+type AddNewPromptVersionDialogProps = {
+  open: boolean;
+  setOpen: (open: boolean) => void;
+  prompt?: PromptWithLatestVersion;
+  template: string;
+  onSave: (promptId: string) => void;
+};
+
+const AddNewPromptVersionDialog: React.FC<AddNewPromptVersionDialogProps> = ({
+  open,
+  setOpen,
+  prompt,
+  template,
+  onSave,
+}) => {
+  const workspaceName = useAppStore((state) => state.activeWorkspaceName);
+  const [promptId, setPromptId] = useState<string | undefined>(prompt?.id);
+  const isEdit = Boolean(promptId);
+
+  const [metadata, setMetadata] = useState(extractMetadata(prompt));
+  const [description, setDescription] = useState("");
+  const [name, setName] = useState("");
+  const [changeDescription, setChangeDescription] = useState("");
+
+  const [showInvalidJSON, setShowInvalidJSON] = useBooleanTimeoutState({});
+  const theme = useCodemirrorTheme({
+    editable: true,
+  });
+
+  const { mutate: newVersionMutate } = useCreatePromptVersionMutation();
+  const { mutate: createMutate } = usePromptCreateMutation();
+
+  const { data: promptData, isPending } = usePromptById(
+    { promptId: promptId! },
+    { enabled: Boolean(promptId) && prompt?.id !== promptId },
+  );
+
+  useEffect(() => {
+    setPromptId(prompt?.id);
+  }, [prompt?.id]);
+
+  const selectedPrompt = useMemo(() => {
+    return !promptId
+      ? undefined
+      : promptId === prompt?.id
+        ? prompt
+        : promptData;
+  }, [prompt, promptData, promptId]);
+
+  useEffect(() => {
+    if (!isPending) {
+      setMetadata(extractMetadata(selectedPrompt));
+    }
+  }, [isPending, selectedPrompt]);
+
+  const isValid = (isEdit ? !isPending : name.length) && template.length;
+
+  const handleClickEditPrompt = () => {
+    const isMetadataValid = metadata === "" || isValidJsonObject(metadata);
+
+    if (!isMetadataValid) {
+      return setShowInvalidJSON(true);
+    }
+
+    if (isEdit) {
+      if (selectedPrompt) {
+        newVersionMutate({
+          name: selectedPrompt?.name,
+          template,
+          changeDescription,
+          ...(metadata && { metadata: safelyParseJSON(metadata) }),
+          onSuccess: (data) => onSave(data.prompt_id),
+        });
+
+        setOpen(false);
+      }
+    } else {
+      createMutate(
+        {
+          prompt: {
+            name,
+            template,
+            ...(metadata && { metadata: safelyParseJSON(metadata) }),
+            ...(description && { description }),
+          },
+        },
+        { onSuccess: (data) => onSave(data?.id) },
+      );
+      setOpen(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogContent className="max-w-lg sm:max-w-[720px]">
+        <DialogHeader>
+          <DialogTitle>Save changes</DialogTitle>
+        </DialogHeader>
+        <DialogAutoScrollBody>
+          <div className="flex flex-col gap-2 pb-4">
+            <Label>Prompt</Label>
+            <PromptsSelectBox
+              onValueChange={setPromptId}
+              value={promptId}
+              clearable={false}
+              refetchOnMount={true}
+              asNewOption={true}
+            />
+          </div>
+          {isEdit ? (
+            <p className="comet-body-s whitespace-pre-wrap break-words pb-4 text-muted-slate">
+              Saving your changes to {selectedPrompt?.name ?? ""} will
+              automatically create a new commit. You can view previous versions
+              anytime in the
+              <Link
+                onClick={(event) => event.stopPropagation()}
+                to="/$workspaceName/prompts/$promptId"
+                params={{ workspaceName, promptId: promptId! }}
+                target="_blank"
+              >
+                <Button variant="link" size="sm">
+                  Prompt library
+                  <SquareArrowOutUpRight className="ml-1.5 mt-1 size-3.5 shrink-0" />
+                </Button>
+              </Link>
+            </p>
+          ) : (
+            <p className="comet-body-s pb-4 text-muted-slate">
+              A new prompt and first commit will be created in the
+              <Link
+                onClick={(event) => event.stopPropagation()}
+                to="/$workspaceName/prompts"
+                params={{ workspaceName }}
+                target="_blank"
+              >
+                <Button variant="link" size="sm">
+                  Prompt library
+                  <SquareArrowOutUpRight className="ml-1.5 mt-1 size-3.5 shrink-0" />
+                </Button>
+              </Link>
+            </p>
+          )}
+          {isEdit ? (
+            <div className="flex flex-col gap-2 pb-4">
+              <Label htmlFor="promptMetadata">Commit message</Label>
+              <Textarea
+                className="comet-code min-h-20"
+                id="promptMetadata"
+                value={changeDescription}
+                onChange={(e) => setChangeDescription(e.target.value)}
+              />
+            </div>
+          ) : (
+            <div className="flex flex-col gap-2 pb-4">
+              <Label htmlFor="promptName">Name</Label>
+              <Input
+                id="promptName"
+                placeholder="Prompt name"
+                value={name}
+                onChange={(event) => setName(event.target.value)}
+              />
+            </div>
+          )}
+
+          <div className="flex flex-col gap-2 border-t border-border pb-4">
+            <Accordion type="multiple">
+              <AccordionItem value="metadata">
+                <AccordionTrigger>Metadata</AccordionTrigger>
+                <AccordionContent>
+                  <div className="rounded-md">
+                    <CodeMirror
+                      theme={theme}
+                      value={metadata}
+                      onChange={setMetadata}
+                      extensions={[jsonLanguage, EditorView.lineWrapping]}
+                    />
+                  </div>
+                  <p className="comet-body-xs mt-2 text-light-slate">
+                    {`Enter a valid JSON object using key-value pairs inside curly braces (e.g. {"key": "value"}).`}
+                  </p>
+                </AccordionContent>
+              </AccordionItem>
+              {showInvalidJSON && (
+                <Alert variant="destructive">
+                  <AlertTitle>Metadata field is not valid</AlertTitle>
+                </Alert>
+              )}
+              {!isEdit && (
+                <AccordionItem value="description">
+                  <AccordionTrigger>Description</AccordionTrigger>
+                  <AccordionContent>
+                    <Textarea
+                      placeholder="Prompt description"
+                      value={description}
+                      onChange={(event) => setDescription(event.target.value)}
+                      maxLength={255}
+                    />
+                  </AccordionContent>
+                </AccordionItem>
+              )}
+            </Accordion>
+          </div>
+        </DialogAutoScrollBody>
+        <DialogFooter>
+          <DialogClose asChild>
+            <Button variant="outline">Cancel</Button>
+          </DialogClose>
+          <Button
+            type="submit"
+            disabled={!isValid}
+            onClick={handleClickEditPrompt}
+          >
+            Save changes
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+};
+
+export default AddNewPromptVersionDialog;
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessage.tsx b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessage.tsx
index bcb8165eb56..a86c15bca96 100644
--- a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessage.tsx
+++ b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessage.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from "react";
+import React, { useRef, useState } from "react";
 import { ChevronDown, CopyPlus, GripHorizontal, Trash } from "lucide-react";
 import CodeMirror from "@uiw/react-codemirror";
 import { EditorView } from "@codemirror/view";
@@ -19,9 +19,11 @@ import { LLM_MESSAGE_ROLE, LLMMessage } from "@/types/llm";
 
 import { cn } from "@/lib/utils";
 import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import Loader from "@/components/shared/Loader/Loader";
 import { mustachePlugin } from "@/constants/codeMirrorPlugins";
 import { DropdownOption } from "@/types/shared";
 import { LLM_MESSAGE_ROLE_NAME_MAP } from "@/constants/llm";
+import LLMPromptMessageActions from "@/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions";
 
 const MESSAGE_TYPE_OPTIONS = [
   {
@@ -58,9 +60,12 @@ const theme = EditorView.theme({
   },
 });
 
-interface LLMPromptMessageProps extends LLMMessage {
+interface LLMPromptMessageProps {
+  message: LLMMessage;
   hideRemoveButton: boolean;
   hideDragButton: boolean;
+  hidePromptActions: boolean;
+  showAlwaysActionsPanel?: boolean;
   onRemoveMessage: () => void;
   onDuplicateMessage: () => void;
   errorText?: string;
@@ -69,17 +74,21 @@ interface LLMPromptMessageProps extends LLMMessage {
 }
 
 const LLMPromptMessage = ({
-  id,
-  content,
-  role,
+  message,
   hideRemoveButton,
   hideDragButton,
+  hidePromptActions,
+  showAlwaysActionsPanel = false,
   errorText,
   possibleTypes = MESSAGE_TYPE_OPTIONS,
   onChangeMessage,
   onDuplicateMessage,
   onRemoveMessage,
 }: LLMPromptMessageProps) => {
+  const [isHoldActionsVisible, setIsHoldActionsVisible] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const { id, role, content } = message;
+
   const { active, attributes, listeners, setNodeRef, transform, transition } =
     useSortable({ id });
 
@@ -107,7 +116,20 @@ const LLMPromptMessage = ({
           },
         )}
       >
-        <div className="absolute right-2 top-2 hidden gap-1 group-hover:flex">
+        <div
+          className={cn(
+            "absolute right-2 top-2 gap-1 group-hover:flex",
+            showAlwaysActionsPanel || isHoldActionsVisible ? "flex" : "hidden",
+          )}
+        >
+          {!hidePromptActions && (
+            <LLMPromptMessageActions
+              message={message}
+              onChangeMessage={onChangeMessage}
+              setIsLoading={setIsLoading}
+              setIsHoldActionsVisible={setIsHoldActionsVisible}
+            />
+          )}
           {!hideRemoveButton && (
             <TooltipWrapper content="Delete a message">
               <Button
@@ -165,22 +187,26 @@ const LLMPromptMessage = ({
               })}
             </DropdownMenuContent>
           </DropdownMenu>
-          <CodeMirror
-            onCreateEditor={(view) => {
-              editorViewRef.current = view;
-            }}
-            theme={theme}
-            value={content}
-            onChange={(c) => onChangeMessage({ content: c })}
-            placeholder="Type your message"
-            basicSetup={{
-              foldGutter: false,
-              allowMultipleSelections: false,
-              lineNumbers: false,
-              highlightActiveLine: false,
-            }}
-            extensions={[EditorView.lineWrapping, mustachePlugin]}
-          />
+          {isLoading ? (
+            <Loader className="min-h-32" />
+          ) : (
+            <CodeMirror
+              onCreateEditor={(view) => {
+                editorViewRef.current = view;
+              }}
+              theme={theme}
+              value={content}
+              onChange={(c) => onChangeMessage({ content: c })}
+              placeholder="Type your message"
+              basicSetup={{
+                foldGutter: false,
+                allowMultipleSelections: false,
+                lineNumbers: false,
+                highlightActiveLine: false,
+              }}
+              extensions={[EditorView.lineWrapping, mustachePlugin]}
+            />
+          )}
         </CardContent>
       </Card>
       {errorText && (
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx
new file mode 100644
index 00000000000..61cfa9bb7b2
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx
@@ -0,0 +1,182 @@
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { RotateCcw, Save } from "lucide-react";
+import isUndefined from "lodash/isUndefined";
+
+import { OnChangeFn } from "@/types/shared";
+import { LLMMessage } from "@/types/llm";
+import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import usePromptById from "@/api/prompts/usePromptById";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import AddNewPromptVersionDialog from "@/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog";
+
+type ConfirmType = "load" | "reset" | "save";
+
+type LLMPromptLibraryActionsProps = {
+  message: LLMMessage;
+  onChangeMessage: (changes: Partial<LLMMessage>) => void;
+  setIsLoading: OnChangeFn<boolean>;
+  setIsHoldActionsVisible: OnChangeFn<boolean>;
+};
+
+const LLMPromptMessageActions: React.FC<LLMPromptLibraryActionsProps> = ({
+  message,
+  onChangeMessage,
+  setIsLoading,
+  setIsHoldActionsVisible,
+}) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean | ConfirmType>(false);
+  const selectedPromptIdRef = useRef<string | undefined>();
+  const tempPromptIdRef = useRef<string | undefined>();
+
+  const { promptId, content } = message;
+  const { data: promptData } = usePromptById(
+    { promptId: promptId! },
+    { enabled: !!promptId },
+  );
+
+  const handleUpdateExternalPromptId = useCallback(
+    (selectedPromptId?: string) => {
+      if (selectedPromptId) {
+        selectedPromptIdRef.current = selectedPromptId;
+        setIsLoading(true);
+      }
+
+      onChangeMessage({
+        promptId: selectedPromptId,
+      });
+    },
+    [onChangeMessage, setIsLoading],
+  );
+
+  const resetHandler = useCallback(() => {
+    onChangeMessage({
+      content: promptData!.latest_version?.template ?? "",
+    });
+  }, [onChangeMessage, promptData]);
+
+  const onSaveHandler = useCallback(
+    (id: string) => {
+      onChangeMessage({
+        promptId: id,
+      });
+    },
+    [onChangeMessage],
+  );
+
+  const resetDisabled =
+    !promptId ||
+    promptData?.id !== promptId ||
+    (promptData?.id === promptId &&
+      message.content === promptData?.latest_version?.template);
+
+  const saveDisabled = message.content === "";
+
+  const confirmConfig = useMemo(() => {
+    const isReset = open === "reset";
+
+    return {
+      onConfirm: () => {
+        isReset
+          ? resetHandler()
+          : handleUpdateExternalPromptId(tempPromptIdRef.current);
+      },
+      title: isReset ? "Reset prompt" : "Load prompt",
+      description: isReset
+        ? "Resetting the prompt will discard all unsaved changes. This action is irreversible. Do you want to proceed?"
+        : "You have unsaved changes in your message field. Loading a new prompt will overwrite them with the prompt’s content. This action cannot be undone.",
+      confirmText: isReset ? "Reset prompt" : "Load prompt",
+    };
+  }, [handleUpdateExternalPromptId, open, resetHandler]);
+
+  useEffect(() => {
+    if (
+      selectedPromptIdRef.current &&
+      selectedPromptIdRef.current === promptId &&
+      selectedPromptIdRef.current === promptData?.id
+    ) {
+      selectedPromptIdRef.current = undefined;
+      onChangeMessage({
+        content: promptData!.latest_version?.template ?? "",
+      });
+      setIsLoading(false);
+    }
+  }, [onChangeMessage, promptData, promptId, setIsLoading]);
+
+  return (
+    <div className="flex h-full flex-1 cursor-default flex-nowrap items-center justify-start gap-2">
+      <div className="flex h-full min-w-40 max-w-60 flex-auto flex-nowrap">
+        <PromptsSelectBox
+          value={promptId}
+          onValueChange={(id) => {
+            if (id !== promptId) {
+              if (content === "" || isUndefined(id)) {
+                handleUpdateExternalPromptId(id);
+              } else {
+                setOpen("load");
+                resetKeyRef.current = resetKeyRef.current + 1;
+                tempPromptIdRef.current = id;
+              }
+            }
+          }}
+          onOpenChange={setIsHoldActionsVisible}
+        />
+      </div>
+      <TooltipWrapper content="Discard changes">
+        <Button
+          variant="outline"
+          size="icon-sm"
+          disabled={resetDisabled}
+          onClick={() => {
+            resetKeyRef.current = resetKeyRef.current + 1;
+            setOpen("reset");
+          }}
+        >
+          <RotateCcw />
+        </Button>
+      </TooltipWrapper>
+
+      <TooltipWrapper content="Save changes">
+        <Button
+          variant="outline"
+          size="icon-sm"
+          disabled={saveDisabled}
+          onClick={() => {
+            resetKeyRef.current = resetKeyRef.current + 1;
+            setOpen("save");
+          }}
+        >
+          <Save />
+        </Button>
+      </TooltipWrapper>
+
+      <Separator orientation="vertical" className="ml-1 mr-2 h-6" />
+
+      <ConfirmDialog
+        key={`confirm-${resetKeyRef.current}`}
+        open={open === "load" || open === "reset"}
+        setOpen={setOpen}
+        {...confirmConfig}
+      />
+      <AddNewPromptVersionDialog
+        key={`save-${resetKeyRef.current}`}
+        open={open === "save"}
+        setOpen={setOpen}
+        prompt={promptData}
+        template={content}
+        onSave={onSaveHandler}
+      />
+    </div>
+  );
+};
+
+export default LLMPromptMessageActions;
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages.tsx b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages.tsx
index c160905dad2..2bd730a4b6d 100644
--- a/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages.tsx
+++ b/apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages.tsx
@@ -28,6 +28,7 @@ interface LLMPromptMessagesProps {
   messages: LLMMessage[];
   validationErrors?: MessageValidationError[];
   possibleTypes?: DropdownOption<LLM_MESSAGE_ROLE>[];
+  hidePromptActions?: boolean;
   onChange: (messages: LLMMessage[]) => void;
   onAddMessage: () => void;
   hint?: string;
@@ -37,6 +38,7 @@ const LLMPromptMessages = ({
   messages,
   validationErrors,
   possibleTypes,
+  hidePromptActions = true,
   onChange,
   onAddMessage,
   hint = "",
@@ -118,6 +120,8 @@ const LLMPromptMessages = ({
                 errorText={validationErrors?.[messageIdx]?.content?.message}
                 hideRemoveButton={messages?.length === 1}
                 hideDragButton={messages?.length === 1}
+                hidePromptActions={hidePromptActions}
+                showAlwaysActionsPanel={messageIdx === messages.length - 1}
                 onRemoveMessage={() => handleRemoveMessage(message.id)}
                 onDuplicateMessage={() =>
                   handleDuplicateMessage(message, messageIdx + 1)
@@ -125,7 +129,7 @@ const LLMPromptMessages = ({
                 onChangeMessage={(changes) =>
                   handleChangeMessage(message.id, changes)
                 }
-                {...message}
+                message={message}
               />
             ))}
           </div>
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
index 25f770515eb..ec12cc62af9 100644
--- a/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
+++ b/apps/opik-frontend/src/components/pages-shared/llm/PromptModelSelect/PromptModelSelect.tsx
@@ -278,7 +278,7 @@ const PromptModelSelect = ({
           })}
         >
           <SelectValue
-            placeholder="Select a LLM model"
+            placeholder="Select an LLM model"
             data-testid="select-a-llm-model"
           >
             <div className="flex items-center gap-2">
diff --git a/apps/opik-frontend/src/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox.tsx b/apps/opik-frontend/src/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox.tsx
new file mode 100644
index 00000000000..15c9499ae8d
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox.tsx
@@ -0,0 +1,159 @@
+import React, { useCallback, useMemo, useState } from "react";
+import { Database, FileTerminal, Plus, X } from "lucide-react";
+import isFunction from "lodash/isFunction";
+
+import useAppStore from "@/store/AppStore";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import LoadableSelectBox from "@/components/shared/LoadableSelectBox/LoadableSelectBox";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import usePromptsList from "@/api/prompts/usePromptsList";
+
+const DEFAULT_LOADED_PROMPTS = 1000;
+const MAX_LOADED_PROMPTS = 10000;
+const NEW_PROMPT_VALUE = "new-prompt";
+
+interface PromptsSelectBoxProps {
+  value?: string;
+  onValueChange: (value?: string) => void;
+  onOpenChange?: (value: boolean) => void;
+  clearable?: boolean;
+  refetchOnMount?: boolean;
+  asNewOption?: boolean;
+}
+
+const PromptsSelectBox: React.FC<PromptsSelectBoxProps> = ({
+  value,
+  onValueChange,
+  onOpenChange,
+  clearable = true,
+  refetchOnMount = false,
+  asNewOption = false,
+}) => {
+  const [open, setOpen] = useState(false);
+  const workspaceName = useAppStore((state) => state.activeWorkspaceName);
+  const [isLoadedMore, setIsLoadedMore] = useState(false);
+  const isClearable = clearable && Boolean(value);
+
+  const { data: promptsData, isLoading: isLoadingPrompts } = usePromptsList(
+    {
+      workspaceName,
+      page: 1,
+      size: !isLoadedMore ? DEFAULT_LOADED_PROMPTS : MAX_LOADED_PROMPTS,
+    },
+    {
+      refetchOnMount,
+    },
+  );
+
+  const prompts = useMemo(
+    () => promptsData?.content ?? [],
+    [promptsData?.content],
+  );
+
+  const promptsOptions = useMemo(() => {
+    return prompts.map(({ name, id }) => ({
+      label: name,
+      value: id,
+    }));
+  }, [prompts]);
+
+  const promptsTotal = promptsData?.total ?? 0;
+
+  const loadMoreHandler = useCallback(() => setIsLoadedMore(true), []);
+
+  const onOpenChangeHandler = useCallback(
+    (open: boolean) => {
+      if (isFunction(onOpenChange)) {
+        onOpenChange(open);
+      }
+
+      setOpen(open);
+    },
+    [onOpenChange],
+  );
+
+  const placeholder = useMemo(
+    () =>
+      asNewOption ? (
+        <div className="flex w-full items-center text-foreground">
+          <Plus className="mr-2 size-4 shrink-0" />
+          <span className="truncate">Save as a new prompt</span>
+        </div>
+      ) : (
+        <div className="flex w-full items-center text-light-slate">
+          <FileTerminal className="mr-2 size-4" />
+          <span className="truncate font-normal">Load a prompt</span>
+        </div>
+      ),
+    [asNewOption],
+  );
+
+  const actionPanel = useMemo(() => {
+    return asNewOption ? (
+      <div className="px-0.5">
+        <Separator className="my-1" />
+        <button
+          className="flex h-10 w-full flex-nowrap items-center px-4 text-foreground"
+          onClick={() => {
+            onValueChange(undefined);
+            setOpen(false);
+          }}
+        >
+          <Plus className="mr-2 size-4 shrink-0" />
+          <span>Save as a new prompt</span>
+        </button>
+      </div>
+    ) : undefined;
+  }, [asNewOption, onValueChange]);
+
+  return (
+    <>
+      <LoadableSelectBox
+        options={promptsOptions}
+        value={value ?? (asNewOption ? NEW_PROMPT_VALUE : "")}
+        placeholder={placeholder}
+        onChange={onValueChange}
+        open={open}
+        onOpenChange={onOpenChangeHandler}
+        buttonSize="sm"
+        onLoadMore={
+          promptsTotal > DEFAULT_LOADED_PROMPTS && !isLoadedMore
+            ? loadMoreHandler
+            : undefined
+        }
+        isLoading={isLoadingPrompts}
+        optionsCount={DEFAULT_LOADED_PROMPTS}
+        buttonClassName={cn("flex-auto max-w-full min-w-1", {
+          "rounded-r-none": isClearable,
+        })}
+        renderTitle={(option) => {
+          return (
+            <div className="flex w-full min-w-1 flex-nowrap items-center text-foreground">
+              <Database className="mr-2 size-4 shrink-0" />
+              <span className="truncate">{option.label}</span>
+            </div>
+          );
+        }}
+        actionPanel={actionPanel}
+        minWidth={540}
+      />
+
+      {isClearable && (
+        <TooltipWrapper content="Remove prompt selection">
+          <Button
+            variant="outline"
+            size="icon-sm"
+            className="shrink-0 rounded-l-none border-l-0"
+            onClick={() => onValueChange(undefined)}
+          >
+            <X className="text-light-slate" />
+          </Button>
+        </TooltipWrapper>
+      )}
+    </>
+  );
+};
+
+export default PromptsSelectBox;
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/PlaygroundOutputActions.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/PlaygroundOutputActions.tsx
index a0ba9754066..b7eeaffc0e4 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/PlaygroundOutputActions.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/PlaygroundOutputActions.tsx
@@ -131,8 +131,8 @@ const PlaygroundOutputActions = ({
 
     const selectLLMModelMessage =
       promptCount === 1
-        ? "Please select a LLM model for your prompt"
-        : "Please select a LLM model for your prompts";
+        ? "Please select an LLM model for your prompt"
+        : "Please select an LLM model for your prompts";
 
     const datasetRemovedMessage = isDatasetRemoved
       ? "Your dataset has been removed. Select another one"
@@ -214,7 +214,7 @@ const PlaygroundOutputActions = ({
           placeholder={
             <div className="flex w-full items-center text-light-slate">
               <Database className="mr-2 size-4" />
-              <span className="truncate font-normal">Dataset</span>
+              <span className="truncate font-normal">Test over dataset</span>
             </div>
           }
           onChange={handleChangeDatasetId}
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx
index 092565b8447..d641d09b3b5 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx
@@ -44,7 +44,7 @@ const PlaygroundPage = () => {
       className="flex h-full w-fit min-w-full flex-col pt-6"
       style={
         {
-          "--min-prompt-width": "540px",
+          "--min-prompt-width": "560px",
           "--item-gap": "1.5rem",
         } as React.CSSProperties
       }
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
index 2f2937a0241..3f2bf5ecca2 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx
@@ -15,6 +15,7 @@ import {
   generateDefaultPrompt,
   getDefaultConfigByProvider,
 } from "@/lib/playground";
+import { PLAYGROUND_LAST_PICKED_MODEL } from "@/constants/llm";
 import { generateDefaultLLMPromptMessage, getNextMessageType } from "@/lib/llm";
 import LLMPromptMessages from "@/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages";
 import PromptModelSelect from "@/components/pages-shared/llm/PromptModelSelect/PromptModelSelect";
@@ -35,8 +36,6 @@ import {
   ProviderResolver,
 } from "@/hooks/useLLMProviderModelsData";
 
-export const PLAYGROUND_LAST_PICKED_MODEL = "playground-last-picked-model";
-
 interface PlaygroundPromptProps {
   workspaceName: string;
   index: number;
@@ -247,6 +246,7 @@ const PlaygroundPrompt = ({
         onChange={handleUpdateMessage}
         onAddMessage={handleAddMessage}
         hint={hintMessage}
+        hidePromptActions={false}
       />
     </div>
   );
diff --git a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
index e95bb4c40ae..092e5296500 100644
--- a/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
+++ b/apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx
@@ -1,11 +1,11 @@
-import React, { useCallback, useEffect } from "react";
-import PlaygroundPrompt, {
-  PLAYGROUND_LAST_PICKED_MODEL,
-} from "@/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import PlaygroundPrompt from "@/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
 import { generateDefaultPrompt } from "@/lib/playground";
 import { PROVIDER_TYPE } from "@/types/providers";
 import { Button } from "@/components/ui/button";
 import { Plus, RotateCcw } from "lucide-react";
+import { PLAYGROUND_LAST_PICKED_MODEL } from "@/constants/llm";
 import {
   useAddPrompt,
   usePromptCount,
@@ -31,6 +31,8 @@ const PlaygroundPrompts = ({
   const promptCount = usePromptCount();
   const addPrompt = useAddPrompt();
   const setPromptMap = useSetPromptMap();
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
 
   const promptIds = usePromptIds();
 
@@ -81,7 +83,14 @@ const PlaygroundPrompts = ({
         <h1 className="comet-title-l">Playground</h1>
 
         <div className="sticky right-0 flex gap-2 ">
-          <Button variant="outline" size="sm" onClick={resetPlayground}>
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => {
+              setOpen(true);
+              resetKeyRef.current = resetKeyRef.current + 1;
+            }}
+          >
             <RotateCcw className="mr-2 size-4" />
             Reset playground
           </Button>
@@ -107,6 +116,15 @@ const PlaygroundPrompts = ({
           />
         ))}
       </div>
+      <ConfirmDialog
+        key={resetKeyRef.current}
+        open={Boolean(open)}
+        setOpen={setOpen}
+        onConfirm={resetPlayground}
+        title="Reset playground"
+        description="Resetting the Playground will discard all unsaved prompts. This action is irreversible. Do you want to proceed?"
+        confirmText="Reset playground"
+      />
     </>
   );
 };
diff --git a/apps/opik-frontend/src/components/pages/PromptPage/ExperimentsTab/ExperimentsTab.tsx b/apps/opik-frontend/src/components/pages/PromptPage/ExperimentsTab/ExperimentsTab.tsx
index 584b0f2b9cf..33f0c5aaac0 100644
--- a/apps/opik-frontend/src/components/pages/PromptPage/ExperimentsTab/ExperimentsTab.tsx
+++ b/apps/opik-frontend/src/components/pages/PromptPage/ExperimentsTab/ExperimentsTab.tsx
@@ -314,11 +314,8 @@ const ExperimentsTab: React.FC<ExperimentsTabProps> = ({ promptId }) => {
   }
 
   return (
-    <div className="pt-6">
-      <div className="mb-4 flex items-center justify-between">
-        <h1 className="comet-title-l truncate break-words">Experiments</h1>
-      </div>
-      <div className="mb-6 flex flex-wrap items-center justify-between gap-x-8 gap-y-2">
+    <div>
+      <div className="mb-4 flex flex-wrap items-center justify-between gap-x-8 gap-y-2">
         <div className="flex items-center gap-2">
           <SearchInput
             searchText={search}
diff --git a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptVersionDialog.tsx b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptVersionDialog.tsx
index 4949a3f5a4b..ffd5010b76f 100644
--- a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptVersionDialog.tsx
+++ b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptVersionDialog.tsx
@@ -82,7 +82,9 @@ const EditPromptVersionDialog: React.FunctionComponent<
       template,
       changeDescription,
       ...(metadata && { metadata: safelyParseJSON(metadata) }),
-      onSetActiveVersionId,
+      onSuccess: (data) => {
+        onSetActiveVersionId(data.id);
+      },
     });
 
     setOpen(false);
@@ -148,6 +150,15 @@ const EditPromptVersionDialog: React.FunctionComponent<
               {"{{variable}}"}.
             </p>
           </div>
+          <div className="flex flex-col gap-2 pb-4">
+            <Label htmlFor="promptMetadata">Commit message</Label>
+            <Textarea
+              className="comet-code min-h-20"
+              id="promptMetadata"
+              value={changeDescription}
+              onChange={(e) => setChangeDescription(e.target.value)}
+            />
+          </div>
           <div className="flex flex-col gap-2 border-t border-border pb-4">
             <Accordion type="multiple">
               <AccordionItem value="metadata">
@@ -162,7 +173,7 @@ const EditPromptVersionDialog: React.FunctionComponent<
                     />
                   </div>
                   <p className="comet-body-xs mt-2 text-light-slate">
-                    You can specify only valid JSON object.
+                    {`Enter a valid JSON object using key-value pairs inside curly braces (e.g. {"key": "value"}).`}
                   </p>
                 </AccordionContent>
               </AccordionItem>
@@ -171,17 +182,6 @@ const EditPromptVersionDialog: React.FunctionComponent<
                   <AlertTitle>Metadata field is not valid</AlertTitle>
                 </Alert>
               )}
-              <AccordionItem value="message">
-                <AccordionTrigger>Commit message</AccordionTrigger>
-                <AccordionContent>
-                  <Textarea
-                    className="comet-code min-h-20"
-                    id="promptMessage"
-                    value={changeDescription}
-                    onChange={(e) => setChangeDescription(e.target.value)}
-                  />
-                </AccordionContent>
-              </AccordionItem>
             </Accordion>
           </div>
         </DialogAutoScrollBody>
diff --git a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/PromptTab.tsx b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/PromptTab.tsx
index d0bc88ee33f..766a2588c76 100644
--- a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/PromptTab.tsx
+++ b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/PromptTab.tsx
@@ -13,6 +13,7 @@ import EditPromptVersionDialog from "@/components/pages/PromptPage/PromptTab/Edi
 import CommitHistory from "@/components/pages/PromptPage/PromptTab/CommitHistory";
 import usePromptVersionsById from "@/api/prompts/usePromptVersionsById";
 import usePromptVersionById from "@/api/prompts/usePromptVersionById";
+import TryInPlaygroundButton from "@/components/pages/PromptPage/TryInPlaygroundButton";
 
 interface PromptTabInterface {
   prompt?: PromptWithLatestVersion;
@@ -76,7 +77,7 @@ const PromptTab = ({ prompt }: PromptTabInterface) => {
   return (
     <>
       <div>
-        <div className="flex w-full items-center">
+        <div className="flex w-full items-center gap-2">
           <Button
             variant="outline"
             size="sm"
@@ -85,7 +86,7 @@ const PromptTab = ({ prompt }: PromptTabInterface) => {
             <Info className="mr-2 size-3.5" />
             Use this prompt
           </Button>
-
+          <TryInPlaygroundButton prompt={prompt} />
           <Button
             className="ml-auto"
             variant="secondary"
@@ -97,7 +98,7 @@ const PromptTab = ({ prompt }: PromptTabInterface) => {
           </Button>
         </div>
 
-        <div className="mt-6 flex gap-6 rounded-md border bg-white p-6">
+        <div className="mt-4 flex gap-6 rounded-md border bg-white p-6">
           <div className="flex grow flex-col gap-2">
             <p className="comet-body-s-accented text-foreground">Prompt</p>
             <code className="comet-code flex w-full whitespace-pre-wrap break-all rounded-md bg-primary-foreground p-3">
diff --git a/apps/opik-frontend/src/components/pages/PromptPage/TryInPlaygroundButton.tsx b/apps/opik-frontend/src/components/pages/PromptPage/TryInPlaygroundButton.tsx
new file mode 100644
index 00000000000..dd25e673c51
--- /dev/null
+++ b/apps/opik-frontend/src/components/pages/PromptPage/TryInPlaygroundButton.tsx
@@ -0,0 +1,121 @@
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Play } from "lucide-react";
+import { useNavigate } from "@tanstack/react-router";
+
+import useAppStore from "@/store/AppStore";
+import { usePromptMap, useSetPromptMap } from "@/store/PlaygroundStore";
+import { PromptWithLatestVersion } from "@/types/prompts";
+import { Button } from "@/components/ui/button";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import { generateDefaultPrompt } from "@/lib/playground";
+import { generateDefaultLLMPromptMessage } from "@/lib/llm";
+import { PLAYGROUND_LAST_PICKED_MODEL } from "@/constants/llm";
+import useLastPickedModel from "@/hooks/useLastPickedModel";
+import useLLMProviderModelsData from "@/hooks/useLLMProviderModelsData";
+import useProviderKeys from "@/api/provider-keys/useProviderKeys";
+
+type TryInPlaygroundButtonProps = {
+  prompt?: PromptWithLatestVersion;
+};
+
+const TryInPlaygroundButton: React.FC<TryInPlaygroundButtonProps> = ({
+  prompt,
+}) => {
+  const workspaceName = useAppStore((state) => state.activeWorkspaceName);
+  const navigate = useNavigate();
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean>(false);
+
+  const promptMap = usePromptMap();
+  const setPromptMap = useSetPromptMap();
+
+  const [lastPickedModel] = useLastPickedModel({
+    key: PLAYGROUND_LAST_PICKED_MODEL,
+  });
+  const { calculateModelProvider, calculateDefaultModel } =
+    useLLMProviderModelsData();
+
+  const { data: providerKeysData, isPending: isPendingProviderKeys } =
+    useProviderKeys({
+      workspaceName,
+    });
+
+  const isPlaygroundEmpty = useMemo(() => {
+    const keys = Object.keys(promptMap);
+
+    return (
+      keys.length === 1 &&
+      promptMap[keys[0]]?.messages?.length === 1 &&
+      promptMap[keys[0]]?.messages[0]?.content === ""
+    );
+  }, [promptMap]);
+
+  const providerKeys = useMemo(() => {
+    return providerKeysData?.content?.map((c) => c.provider) || [];
+  }, [providerKeysData]);
+
+  const loadPlayground = useCallback(() => {
+    const newPrompt = generateDefaultPrompt({
+      setupProviders: providerKeys,
+      lastPickedModel,
+      providerResolver: calculateModelProvider,
+      modelResolver: calculateDefaultModel,
+    });
+
+    newPrompt.messages = [
+      generateDefaultLLMPromptMessage({
+        content: prompt?.latest_version?.template ?? "",
+        promptId: prompt?.id,
+      }),
+    ];
+
+    setPromptMap([newPrompt.id], { [newPrompt.id]: newPrompt });
+    navigate({
+      to: "/$workspaceName/playground",
+      params: {
+        workspaceName,
+      },
+    });
+  }, [
+    calculateDefaultModel,
+    calculateModelProvider,
+    lastPickedModel,
+    navigate,
+    prompt,
+    providerKeys,
+    setPromptMap,
+    workspaceName,
+  ]);
+
+  return (
+    <>
+      <Button
+        variant="outline"
+        size="sm"
+        disabled={!prompt || isPendingProviderKeys}
+        onClick={() => {
+          if (isPlaygroundEmpty) {
+            loadPlayground();
+          } else {
+            resetKeyRef.current = resetKeyRef.current + 1;
+            setOpen(true);
+          }
+        }}
+      >
+        <Play className="mr-2 size-3.5" />
+        Try in the Playground
+      </Button>
+      <ConfirmDialog
+        key={resetKeyRef.current}
+        open={Boolean(open)}
+        setOpen={setOpen}
+        onConfirm={loadPlayground}
+        title="Load prompt"
+        description="Loading this prompt into the Playground will replace any unsaved changes. This action cannot be undone."
+        confirmText="Load prompt"
+      />
+    </>
+  );
+};
+
+export default TryInPlaygroundButton;
diff --git a/apps/opik-frontend/src/components/pages/PromptsPage/AddEditPromptDialog.tsx b/apps/opik-frontend/src/components/pages/PromptsPage/AddEditPromptDialog.tsx
index d7a91eb6120..1ea1409ff57 100644
--- a/apps/opik-frontend/src/components/pages/PromptsPage/AddEditPromptDialog.tsx
+++ b/apps/opik-frontend/src/components/pages/PromptsPage/AddEditPromptDialog.tsx
@@ -173,7 +173,7 @@ const AddEditPromptDialog: React.FunctionComponent<AddPromptDialogProps> = ({
                       />
                     </div>
                     <p className="comet-body-xs mt-2 text-light-slate">
-                      You can specify only valid JSON object.
+                      {`Enter a valid JSON object using key-value pairs inside curly braces (e.g. {"key": "value"}).`}
                     </p>
                   </AccordionContent>
                 </AccordionItem>
diff --git a/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx b/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx
index 6306a2c2e51..ce3eb3db11a 100644
--- a/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx
+++ b/apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx
@@ -21,6 +21,8 @@ export type LoadableSelectBoxProps = {
   value?: string;
   placeholder?: ReactElement | string;
   onChange: (value: string) => void;
+  open?: boolean;
+  onOpenChange?: (open: boolean) => void;
   options: DropdownOption<string>[];
   variant?: "outline" | "ghost";
   optionsCount?: number;
@@ -30,12 +32,16 @@ export type LoadableSelectBoxProps = {
   buttonSize?: ButtonProps["size"];
   buttonClassName?: string;
   renderTitle?: (option: DropdownOption<string>) => ReactElement;
+  actionPanel?: ReactElement;
+  minWidth?: number;
 };
 
 export const LoadableSelectBox = ({
   value = "",
   placeholder = "Select value",
   onChange,
+  open: controlledOpen,
+  onOpenChange,
   options,
   buttonSize = "default",
   buttonClassName = "w-full",
@@ -44,11 +50,16 @@ export const LoadableSelectBox = ({
   disabled,
   onLoadMore,
   renderTitle: parentRenderTitle,
+  actionPanel,
+  minWidth = 0,
 }: LoadableSelectBoxProps) => {
-  const [open, setOpen] = useState(false);
+  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
   const [search, setSearch] = useState("");
   const [width, setWidth] = useState<number | undefined>();
 
+  const isOpen =
+    controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
+
   const hasMore = isFunction(onLoadMore);
 
   const noDataText = search
@@ -75,12 +86,21 @@ export const LoadableSelectBox = ({
     return options.filter((o) => toLower(o.label).includes(toLower(search)));
   }, [options, search]);
 
-  const openChangeHandler = useCallback((open: boolean) => {
-    if (!open) {
-      setSearch("");
-    }
-    setOpen(open);
-  }, []);
+  const openChangeHandler = useCallback(
+    (open: boolean) => {
+      if (!open) {
+        setSearch("");
+      }
+      // Only update internal state if not controlled
+      if (controlledOpen === undefined) {
+        setUncontrolledOpen(open);
+      }
+      if (isFunction(onOpenChange)) {
+        onOpenChange(open);
+      }
+    },
+    [controlledOpen, onOpenChange],
+  );
 
   const { ref } = useObserveResizeNode<HTMLButtonElement>((node) =>
     setWidth(node.clientWidth),
@@ -88,9 +108,11 @@ export const LoadableSelectBox = ({
 
   const hasFilteredOptions = Boolean(filteredOptions.length);
   const hasMoreSection = hasFilteredOptions && hasMore;
+  const hasActionPanel = Boolean(actionPanel);
+  const hasBottomActions = hasMoreSection || hasActionPanel;
 
   return (
-    <Popover onOpenChange={openChangeHandler} open={open} modal>
+    <Popover onOpenChange={openChangeHandler} open={isOpen} modal>
       <PopoverTrigger asChild>
         <Button
           className={cn("justify-between", buttonClassName)}
@@ -105,11 +127,11 @@ export const LoadableSelectBox = ({
         </Button>
       </PopoverTrigger>
       <PopoverContent
-        align="start"
+        align="end"
         style={
           width
             ? {
-                width: `${width}px`,
+                width: `${Math.max(width, minWidth)}px`,
               }
             : {}
         }
@@ -155,14 +177,19 @@ export const LoadableSelectBox = ({
           )}
         </div>
 
-        {hasMoreSection && (
-          <div className="sticky inset-x-0 bottom-0 flex items-center justify-between px-4">
-            <div className="comet-body-s text-muted-slate">
-              {`Showing first ${optionsCount} items.`}
-            </div>
-            <Button variant="link" onClick={onLoadMore}>
-              Load more
-            </Button>
+        {hasBottomActions && (
+          <div className="sticky inset-x-0 bottom-0">
+            {hasMoreSection && (
+              <div className="flex items-center justify-between px-4">
+                <div className="comet-body-s text-muted-slate">
+                  {`Showing first ${optionsCount} items.`}
+                </div>
+                <Button variant="link" onClick={onLoadMore}>
+                  Load more
+                </Button>
+              </div>
+            )}
+            {hasActionPanel && actionPanel}
           </div>
         )}
       </PopoverContent>
diff --git a/apps/opik-frontend/src/components/shared/Loader/Loader.tsx b/apps/opik-frontend/src/components/shared/Loader/Loader.tsx
index 38bbf4cd631..8ccd318c4d5 100644
--- a/apps/opik-frontend/src/components/shared/Loader/Loader.tsx
+++ b/apps/opik-frontend/src/components/shared/Loader/Loader.tsx
@@ -1,15 +1,23 @@
 import React from "react";
+import { cn } from "@/lib/utils";
 import { Spinner } from "@/components/ui/spinner";
 
 type LoaderProps = {
   message?: string;
+  className?: string;
 };
 
 const Loader: React.FunctionComponent<LoaderProps> = ({
   message = "Loading",
+  className = "min-h-96",
 }) => {
   return (
-    <div className="flex h-full min-h-96 flex-col items-center justify-center">
+    <div
+      className={cn(
+        "flex h-full flex-col items-center justify-center",
+        className,
+      )}
+    >
       <Spinner />
       {message}
     </div>
diff --git a/apps/opik-frontend/src/components/shared/MarkdownPreview/MarkdownPreview.tsx b/apps/opik-frontend/src/components/shared/MarkdownPreview/MarkdownPreview.tsx
index f62e45926cd..aab9f09f376 100644
--- a/apps/opik-frontend/src/components/shared/MarkdownPreview/MarkdownPreview.tsx
+++ b/apps/opik-frontend/src/components/shared/MarkdownPreview/MarkdownPreview.tsx
@@ -27,11 +27,7 @@ export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
       </ReactMarkdown>
     );
   } else {
-    return (
-      <div className={cn("comet-markdown whitespace-pre-wrap", className)}>
-        {children}
-      </div>
-    );
+    return <div className={cn("comet-markdown", className)}>{children}</div>;
   }
 };
 
diff --git a/apps/opik-frontend/src/constants/llm.ts b/apps/opik-frontend/src/constants/llm.ts
index e7ecd827396..270913f29af 100644
--- a/apps/opik-frontend/src/constants/llm.ts
+++ b/apps/opik-frontend/src/constants/llm.ts
@@ -5,6 +5,8 @@ import {
   LLM_SCHEMA_TYPE,
 } from "@/types/llm";
 
+export const PLAYGROUND_LAST_PICKED_MODEL = "playground-last-picked-model";
+
 export const LLM_MESSAGE_ROLE_NAME_MAP = {
   [LLM_MESSAGE_ROLE.system]: "System",
   [LLM_MESSAGE_ROLE.assistant]: "Assistant",
diff --git a/apps/opik-frontend/src/main.scss b/apps/opik-frontend/src/main.scss
index e57debe5569..8973318b9f0 100644
--- a/apps/opik-frontend/src/main.scss
+++ b/apps/opik-frontend/src/main.scss
@@ -320,9 +320,15 @@
   font-weight: 400;
   letter-spacing: 0.02em;
   max-width: 100%;
+  white-space: pre-wrap;
+  overflow-wrap: break-word;
+  word-break: break-word;
 
   pre code {
     font-size: 0.875rem !important;
+    white-space: pre-wrap;
+    overflow-wrap: break-word;
+    word-break: break-word;
   }
 }
 
diff --git a/apps/opik-frontend/src/types/llm.ts b/apps/opik-frontend/src/types/llm.ts
index a6b5735ab92..a42a555c5bf 100644
--- a/apps/opik-frontend/src/types/llm.ts
+++ b/apps/opik-frontend/src/types/llm.ts
@@ -24,6 +24,7 @@ export interface LLMMessage {
   id: string;
   content: string;
   role: LLM_MESSAGE_ROLE;
+  promptId?: string;
 }
 
 export type ProviderMessageType = Omit<LLMMessage, "id">;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Adds a prompt selector and confirmation dialogs to the Playground interface, persists the last-picked model, and extends shared components to support new prompt actions and UI tweaks.

- Introduce a new `PLAYGROUND_LAST_PICKED_MODEL` constant and wire it into Playground components.
- Enhance `LoadableSelectBox` and `LLMPromptMessages` with controlled open state, action panels, and prompt action visibility.
- Add confirm dialogs for loading and resetting prompts, plus minor spacing and placeholder text updates.

### Reviewed Changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| apps/opik-frontend/src/constants/llm.ts | Add `PLAYGROUND_LAST_PICKED_MODEL` constant. |
| apps/opik-frontend/src/components/shared/MarkdownPreview/MarkdownPreview.tsx | Simplify wrapper div to remove unnecessary whitespace class. |
| apps/opik-frontend/src/components/shared/Loader/Loader.tsx | Inject `cn` utility, add `className` prop, remove fixed min-height. |
| apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx | Add controlled `open`/`onOpenChange`, `actionPanel`, `minWidth`, adjust alignment and width logic. |
| apps/opik-frontend/src/components/pages/PromptsPage/AddEditPromptDialog.tsx | Update JSON hint copy to be more descriptive. |
| apps/opik-frontend/src/components/pages/PromptPage/TryInPlaygroundButton.tsx | New button with confirm dialog to load a prompt into Playground. |
| apps/opik-frontend/src/components/pages/PromptPage/PromptTab/PromptTab.tsx | Insert `TryInPlaygroundButton`, adjust spacing and layout. |
| apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptVersionDialog.tsx | Move commit message field out of accordion, update JSON hint copy. |
| apps/opik-frontend/src/components/pages/PromptPage/ExperimentsTab/ExperimentsTab.tsx | Remove extra padding container, simplify header layout. |
| apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompts.tsx | Add confirm dialog around playground reset, persist reset key. |
| apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPrompts/PlaygroundPrompt.tsx | Remove duplicate constant, import shared one, set `hidePromptActions`. |
| apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx | Update CSS variable `--min-prompt-width` from 540px to 560px. |
| apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundOutputs/PlaygroundOutputActions/PlaygroundOutputActions.tsx | Change dataset placeholder to “Test over dataset.” |
| apps/opik-frontend/src/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox.tsx | New select box for prompts with load and “save as new” actions. |
| apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessages.tsx | Add `hidePromptActions`, `showAlwaysActionsPanel` props and prop rename. |
| apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx | New actions component for load/reset/save of individual prompt messages. |
| apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessage.tsx | Integrate new actions component, loading state, and action visibility. |
| apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog.tsx | Refactor commit message and description fields, update JSON hint copy. |
| apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts | Rename callback param from `onSetActiveVersionId` to `onSuccess`. |
| apps/opik-frontend/src/api/playground/createLogPlaygroundProcessor.ts | Include `model_config` in logged experiment metadata. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**apps/opik-frontend/src/components/shared/LoadableSelectBox/LoadableSelectBox.tsx:24**
* New controlled `open` and `onOpenChange` props introduce both controlled and uncontrolled behavior—consider adding unit tests to cover these states and ensure onOpenChange is invoked correctly.
```
open?: boolean;
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T09:57:08Z
user: login: aadereiko
body: Good work! The system is getting more and more complex ☄️ 
The code looks nice and functionality as well. There as one thing that needs to be fixed, but you can do it in the next PR just to avoid conflicts:
After Saving as a new prompt, the prompt is not selected automatically.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T04:41:47Z
user: login: andriidudar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:24:02Z

Review comments:
diff_hunk: @@ -0,0 +1,182 @@
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { RotateCcw, Save } from "lucide-react";
+import isUndefined from "lodash/isUndefined";
+
+import { OnChangeFn } from "@/types/shared";
+import { LLMMessage } from "@/types/llm";
+import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import usePromptById from "@/api/prompts/usePromptById";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import AddNewPromptVersionDialog from "@/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog";
+
+type ConfirmType = "load" | "reset" | "save";
+
+type LLMPromptLibraryActionsProps = {
+  message: LLMMessage;
+  onChangeMessage: (changes: Partial<LLMMessage>) => void;
+  setIsLoading: OnChangeFn<boolean>;
+  setIsHoldActionsVisible: OnChangeFn<boolean>;
+};
+
+const LLMPromptMessageActions: React.FC<LLMPromptLibraryActionsProps> = ({
+  message,
+  onChangeMessage,
+  setIsLoading,
+  setIsHoldActionsVisible,
+}) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean | ConfirmType>(false);
+  const selectedPromptIdRef = useRef<string | undefined>();
+  const tempPromptIdRef = useRef<string | undefined>();
+
+  const { promptId, content } = message;
+  const { data: promptData } = usePromptById(
+    { promptId: promptId! },
+    { enabled: !!promptId },
+  );
+
+  const handleUpdateExternalPromptId = useCallback(
+    (selectedPromptId?: string) => {
+      if (selectedPromptId) {
+        selectedPromptIdRef.current = selectedPromptId;
+        setIsLoading(true);
+      }
+
+      onChangeMessage({
+        promptId: selectedPromptId,
+      });
+    },
+    [onChangeMessage, setIsLoading],
+  );
+
+  const resetHandler = useCallback(() => {
+    onChangeMessage({
+      content: promptData!.latest_version?.template ?? "",
+    });
+  }, [onChangeMessage, promptData]);
+
+  const onSaveHandler = useCallback(
+    (id: string) => {
+      onChangeMessage({
+        promptId: id,
+      });
+    },
+    [onChangeMessage],
+  );
+
+  const resetDisabled =
+    !promptId ||
+    promptData?.id !== promptId ||
+    (promptData?.id === promptId &&
+      message.content === promptData?.latest_version?.template);
+
+  const saveDisabled = message.content === "";
+
+  const confirmConfig = useMemo(() => {
+    const isReset = open === "reset";
+
+    return {
+      onConfirm: () => {
+        isReset
+          ? resetHandler()
+          : handleUpdateExternalPromptId(tempPromptIdRef.current);
+      },
+      title: isReset ? "Reset prompt" : "Load prompt",
+      description: isReset
+        ? "Resetting the prompt will discard all unsaved changes. This action is irreversible. Do you want to proceed?"
+        : "You have unsaved changes in your message field. Loading a new prompt will overwrite them with the prompt’s content. This action cannot be undone.",
+      confirmText: isReset ? "Reset prompt" : "Load prompt",
+    };
+  }, [handleUpdateExternalPromptId, open, resetHandler]);
+
+  useEffect(() => {
+    if (
+      selectedPromptIdRef.current &&
+      selectedPromptIdRef.current === promptId &&
+      selectedPromptIdRef.current === promptData?.id
+    ) {
+      selectedPromptIdRef.current = undefined;
+      onChangeMessage({
+        content: promptData!.latest_version?.template ?? "",
+      });
+      setIsLoading(false);
+    }
+  }, [onChangeMessage, promptData, promptId, setIsLoading]);
+
+  return (
+    <div className="flex h-full flex-1 cursor-default flex-nowrap items-center justify-start gap-2">
+      <div className="flex h-full min-w-40 max-w-60 flex-auto flex-nowrap">
+        <PromptsSelectBox
+          value={promptId}
+          onValueChange={(id) => {
+            if (id !== promptId) {
+              if (content === "" || isUndefined(id)) {
+                handleUpdateExternalPromptId(id);
+              } else {
+                setOpen("load");
+                resetKeyRef.current = resetKeyRef.current + 1;
+                tempPromptIdRef.current = id;
+              }
+            }
+          }}
+          onOpenChange={setIsHoldActionsVisible}
+        />
+      </div>
+      <TooltipWrapper content="Discurd changes">
path: apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx
user: login: Copilot
body: Typo in tooltip text: replace "Discurd changes" with "Discard changes".
```suggestion
      <TooltipWrapper content="Discard changes">
```
created_at: 2025-05-16T09:57:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,264 @@
+import React, { useEffect, useMemo, useState } from "react";
+import CodeMirror from "@uiw/react-codemirror";
+import { jsonLanguage } from "@codemirror/lang-json";
+import { EditorView } from "@codemirror/view";
+import { SquareArrowOutUpRight } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+
+import useAppStore from "@/store/AppStore";
+import {
+  Dialog,
+  DialogAutoScrollBody,
+  DialogClose,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertTitle } from "@/components/ui/alert";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "@/components/ui/accordion";
+import useCreatePromptVersionMutation from "@/api/prompts/useCreatePromptVersionMutation";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import { useBooleanTimeoutState } from "@/hooks/useBooleanTimeoutState";
+import { useCodemirrorTheme } from "@/hooks/useCodemirrorTheme";
+import { isValidJsonObject, safelyParseJSON } from "@/lib/utils";
+import { PromptWithLatestVersion } from "@/types/prompts";
+import usePromptById from "@/api/prompts/usePromptById";
+import usePromptCreateMutation from "@/api/prompts/usePromptCreateMutation";
+
+const extractMetadata = (prompt?: PromptWithLatestVersion) => {
+  return prompt?.latest_version?.metadata
+    ? JSON.stringify(prompt.latest_version.metadata, null, 2)
+    : "";
+};
+
+type AddNewPromptVersionDialogProps = {
+  open: boolean;
+  setOpen: (open: boolean) => void;
+  prompt?: PromptWithLatestVersion;
+  template: string;
+  onSave: (promptId: string) => void;
+};
+
+const AddNewPromptVersionDialog: React.FC<AddNewPromptVersionDialogProps> = ({
+  open,
+  setOpen,
+  prompt,
+  template,
+  onSave,
+}) => {
+  const workspaceName = useAppStore((state) => state.activeWorkspaceName);
+  const [promptId, setPromptId] = useState<string | undefined>(prompt?.id);
+  const isEdit = Boolean(promptId);
+
+  const [metadata, setMetadata] = useState(extractMetadata(prompt));
+  const [description, setDescription] = useState("");
+  const [name, setName] = useState("");
+  const [changeDescription, setChangeDescription] = useState("");
+
+  const [showInvalidJSON, setShowInvalidJSON] = useBooleanTimeoutState({});
+  const theme = useCodemirrorTheme({
+    editable: true,
+  });
+
+  const { mutate: newVersionMutate } = useCreatePromptVersionMutation();
+  const { mutate: createMutate } = usePromptCreateMutation();
+
+  const { data: promptData, isPending } = usePromptById(
+    { promptId: promptId! },
+    { enabled: Boolean(promptId) && prompt?.id !== promptId },
+  );
+
+  useEffect(() => {
+    setPromptId(prompt?.id);
+  }, [prompt?.id]);
+
+  const selectedPrompt = useMemo(() => {
+    return !promptId
+      ? undefined
+      : promptId === prompt?.id
+        ? prompt
+        : promptData;
+  }, [prompt, promptData, promptId]);
+
+  useEffect(() => {
+    if (!isPending) {
+      setMetadata(extractMetadata(selectedPrompt));
+    }
+  }, [isPending, selectedPrompt]);
+
+  const isValid = (isEdit ? !isPending : name.length) && template.length;
+
+  const handleClickEditPrompt = () => {
+    const isMetadataValid = metadata === "" || isValidJsonObject(metadata);
+
+    if (!isMetadataValid) {
+      return setShowInvalidJSON(true);
+    }
+
+    if (isEdit) {
+      if (selectedPrompt) {
+        newVersionMutate({
+          name: selectedPrompt?.name,
+          template,
+          changeDescription,
+          ...(metadata && { metadata: safelyParseJSON(metadata) }),
+          onSuccess: (data) => onSave(data.prompt_id),
+        });
+
+        setOpen(false);
+      }
+    } else {
+      createMutate(
+        {
+          prompt: {
+            name,
+            template,
+            ...(metadata && { metadata: safelyParseJSON(metadata) }),
+            ...(description && { description }),
+          },
+        },
+        { onSuccess: (data) => onSave(data?.id) },
+      );
+      setOpen(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogContent className="max-w-lg sm:max-w-[720px]">
+        <DialogHeader>
+          <DialogTitle>Save changes</DialogTitle>
+        </DialogHeader>
+        <DialogAutoScrollBody>
+          <div className="flex flex-col gap-2 pb-4">
+            <Label>Prompt</Label>
+            <PromptsSelectBox
+              onValueChange={setPromptId}
+              value={promptId}
+              clearable={false}
+              refetchOnMount={true}
+              asNewOption={true}
+            />
+          </div>
+          {isEdit ? (
+            <p className="comet-body-s whitespace-pre-wrap break-words pb-4 text-muted-slate">
+              By saving your changes to {selectedPrompt?.name ?? ""}, a new
+              commit will be created automatically. You can view and access
+              previous versions of your prompt on the
+              <Link
+                onClick={(event) => event.stopPropagation()}
+                to="/$workspaceName/prompts/$promptId"
+                params={{ workspaceName, promptId: promptId! }}
+                target="_blank"
+              >
+                <Button variant="link" size="sm">
+                  Prompt library
+                  <SquareArrowOutUpRight className="ml-1.5 mt-1 size-3.5 shrink-0" />
+                </Button>
+              </Link>
+            </p>
+          ) : (
+            <p className="comet-body-s pb-4 text-muted-slate">
+              Prompt Save as a new prompt A new prompt will be created in the
path: apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog.tsx
user: login: Copilot
body: [nitpick] The instructional text is grammatically unclear; consider rephrasing to something like "Save as a new prompt. A new prompt will be created in the Prompt library."
```suggestion
              Save as a new prompt. A new prompt will be created in the
```
created_at: 2025-05-16T09:57:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,7 +44,7 @@ const PlaygroundPage = () => {
       className="flex h-full w-fit min-w-full flex-col pt-6"
       style={
         {
-          "--min-prompt-width": "540px",
+          "--min-prompt-width": "560px",
path: apps/opik-frontend/src/components/pages/PlaygroundPage/PlaygroundPage.tsx
user: login: Copilot
body: [nitpick] Extract the magic value `560px` into a named constant or document its purpose to improve readability and ease future adjustments.
```suggestion
          "--min-prompt-width": MIN_PROMPT_WIDTH,
```
created_at: 2025-05-16T09:57:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { RotateCcw, Save } from "lucide-react";
+import isUndefined from "lodash/isUndefined";
+
+import { OnChangeFn } from "@/types/shared";
+import { LLMMessage } from "@/types/llm";
+import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import usePromptById from "@/api/prompts/usePromptById";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import AddNewPromptVersionDialog from "@/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog";
+
+type ConfirmType = "load" | "reset" | "save";
+
+type LLMPromptLibraryActionsProps = {
+  message: LLMMessage;
+  onChangeMessage: (changes: Partial<LLMMessage>) => void;
+  setIsLoading: OnChangeFn<boolean>;
+  setIsHoldActionsVisible: OnChangeFn<boolean>;
+};
+
+const LLMPromptMessageActions: React.FC<LLMPromptLibraryActionsProps> = ({
+  message,
+  onChangeMessage,
+  setIsLoading,
+  setIsHoldActionsVisible,
+}) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean | ConfirmType>(false);
+  const selectedPromptIdRef = useRef<string | undefined>();
+  const tempPromptIdRef = useRef<string | undefined>();
+
+  const { promptId, content } = message;
+  const { data: promptData } = usePromptById(
+    { promptId: promptId! },
+    { enabled: !!promptId },
+  );
+
+  const handleUpdateExternalPromptId = useCallback(
+    (selectedPromptId?: string) => {
+      if (selectedPromptId) {
+        selectedPromptIdRef.current = selectedPromptId;
+        setIsLoading(true);
+      }
+
+      onChangeMessage({
+        promptId: selectedPromptId,
+      });
+    },
+    [onChangeMessage, setIsLoading],
+  );
+
+  const resetHandler = useCallback(() => {
+    onChangeMessage({
+      content: promptData!.latest_version?.template ?? "",
+    });
+  }, [onChangeMessage, promptData]);
+
+  const onSaveHandler = useCallback(
+    (id: string) => {
+      onChangeMessage({
+        promptId: id,
+      });
+    },
+    [onChangeMessage],
+  );
+
+  const resetDisabled =
+    !promptId ||
+    promptData?.id !== promptId ||
+    (promptData?.id === promptId &&
+      message.content === promptData?.latest_version?.template);
+
+  const saveDisabled = message.content === "";
+
+  const confirmConfig = useMemo(() => {
+    const isReset = open === "reset";
+
+    return {
+      onConfirm: () => {
+        isReset
+          ? resetHandler()
+          : handleUpdateExternalPromptId(tempPromptIdRef.current);
+      },
+      title: isReset ? "Reset prompt" : "Load prompt",
+      description: isReset
+        ? "Resetting the prompt will discard all unsaved changes. This action is irreversible. Do you want to proceed?"
+        : "You have unsaved changes in your message field. Loading a new prompt will overwrite them with the prompt’s content. This action cannot be undone.",
+      confirmText: isReset ? "Reset prompt" : "Load prompt",
+    };
+  }, [handleUpdateExternalPromptId, open, resetHandler]);
+
+  useEffect(() => {
+    if (
+      selectedPromptIdRef.current &&
+      selectedPromptIdRef.current === promptId &&
+      selectedPromptIdRef.current === promptData?.id
+    ) {
+      selectedPromptIdRef.current = undefined;
+      onChangeMessage({
+        content: promptData!.latest_version?.template ?? "",
+      });
+      setIsLoading(false);
+    }
+  }, [onChangeMessage, promptData, promptId, setIsLoading]);
path: apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx
user: login: aadereiko
body: it would be nice to leave a comment what this useEffect does in the future
created_at: 2025-05-20T04:30:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,182 @@
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { RotateCcw, Save } from "lucide-react";
+import isUndefined from "lodash/isUndefined";
+
+import { OnChangeFn } from "@/types/shared";
+import { LLMMessage } from "@/types/llm";
+import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
+import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper";
+import usePromptById from "@/api/prompts/usePromptById";
+import PromptsSelectBox from "@/components/pages-shared/llm/PromptsSelectBox/PromptsSelectBox";
+import ConfirmDialog from "@/components/shared/ConfirmDialog/ConfirmDialog";
+import AddNewPromptVersionDialog from "@/components/pages-shared/llm/LLMPromptMessages/AddNewPromptVersionDialog";
+
+type ConfirmType = "load" | "reset" | "save";
+
+type LLMPromptLibraryActionsProps = {
+  message: LLMMessage;
+  onChangeMessage: (changes: Partial<LLMMessage>) => void;
+  setIsLoading: OnChangeFn<boolean>;
+  setIsHoldActionsVisible: OnChangeFn<boolean>;
+};
+
+const LLMPromptMessageActions: React.FC<LLMPromptLibraryActionsProps> = ({
+  message,
+  onChangeMessage,
+  setIsLoading,
+  setIsHoldActionsVisible,
+}) => {
+  const resetKeyRef = useRef(0);
+  const [open, setOpen] = useState<boolean | ConfirmType>(false);
+  const selectedPromptIdRef = useRef<string | undefined>();
+  const tempPromptIdRef = useRef<string | undefined>();
+
+  const { promptId, content } = message;
+  const { data: promptData } = usePromptById(
+    { promptId: promptId! },
+    { enabled: !!promptId },
+  );
+
+  const handleUpdateExternalPromptId = useCallback(
+    (selectedPromptId?: string) => {
+      if (selectedPromptId) {
+        selectedPromptIdRef.current = selectedPromptId;
+        setIsLoading(true);
+      }
+
+      onChangeMessage({
+        promptId: selectedPromptId,
+      });
+    },
+    [onChangeMessage, setIsLoading],
+  );
+
+  const resetHandler = useCallback(() => {
+    onChangeMessage({
+      content: promptData!.latest_version?.template ?? "",
+    });
+  }, [onChangeMessage, promptData]);
+
+  const onSaveHandler = useCallback(
+    (id: string) => {
+      onChangeMessage({
+        promptId: id,
+      });
+    },
+    [onChangeMessage],
+  );
+
+  const resetDisabled =
+    !promptId ||
+    promptData?.id !== promptId ||
+    (promptData?.id === promptId &&
+      message.content === promptData?.latest_version?.template);
+
+  const saveDisabled = message.content === "";
+
+  const confirmConfig = useMemo(() => {
+    const isReset = open === "reset";
+
+    return {
+      onConfirm: () => {
+        isReset
+          ? resetHandler()
+          : handleUpdateExternalPromptId(tempPromptIdRef.current);
+      },
+      title: isReset ? "Reset prompt" : "Load prompt",
+      description: isReset
+        ? "Resetting the prompt will discard all unsaved changes. This action is irreversible. Do you want to proceed?"
+        : "You have unsaved changes in your message field. Loading a new prompt will overwrite them with the prompt’s content. This action cannot be undone.",
+      confirmText: isReset ? "Reset prompt" : "Load prompt",
+    };
+  }, [handleUpdateExternalPromptId, open, resetHandler]);
+
+  useEffect(() => {
+    if (
+      selectedPromptIdRef.current &&
+      selectedPromptIdRef.current === promptId &&
+      selectedPromptIdRef.current === promptData?.id
+    ) {
+      selectedPromptIdRef.current = undefined;
+      onChangeMessage({
+        content: promptData!.latest_version?.template ?? "",
+      });
+      setIsLoading(false);
+    }
+  }, [onChangeMessage, promptData, promptId, setIsLoading]);
path: apps/opik-frontend/src/components/pages-shared/llm/LLMPromptMessages/LLMPromptMessageActions.tsx
user: login: andriidudar
body: Will do in follow-up PR, thanks 
created_at: 2025-05-20T07:24:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: andriidudar
created_at: 2025-05-20T07:25:48Z
body: > Good work! The system is getting more and more complex ☄️ The code looks nice and functionality as well. There as one thing that needs to be fixed, but you can do it in the next PR just to avoid conflicts: After Saving as a new prompt, the prompt is not selected automatically.

Thanks for the review, the issue with saving as new is reproducing only on setup when the local version of FE works with the remote server (because of CORS and limitation to read response headers). It is not a real issue
author_association: CONTRIBUTOR
user: login: mrpher
created_at: 2025-05-20T16:02:01Z
body: 👏 👏 
author_association: NONE