From 104b9cfc6e17aefb70a43320e57997c8a1da8029 Mon Sep 17 00:00:00 2001 From: Mikyo King Date: Wed, 9 Oct 2024 13:14:06 -0600 Subject: [PATCH] feat(playground): re-ordering of messages using dnd (#4936) --- app/package.json | 2 + app/pnpm-lock.yaml | 71 ++++++ app/src/components/dnd/DragHandle.tsx | 35 +++ app/src/components/dnd/helpers/move.ts | 208 ++++++++++++++++ .../playground/PlaygroundChatTemplate.tsx | 223 +++++++++++------- .../__tests__/playgroundUtils.test.ts | 38 ++- app/src/pages/playground/playgroundUtils.ts | 2 + app/src/store/playgroundStore.tsx | 26 +- 8 files changed, 506 insertions(+), 99 deletions(-) create mode 100644 app/src/components/dnd/DragHandle.tsx create mode 100644 app/src/components/dnd/helpers/move.ts diff --git a/app/package.json b/app/package.json index 90443e8bbc..1186545860 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,8 @@ "@codemirror/lang-python": "6.1.3", "@codemirror/lint": "^6.8.1", "@codemirror/view": "^6.28.5", + "@dnd-kit/abstract": "^0.0.5", + "@dnd-kit/react": "^0.0.5", "@react-three/drei": "^9.108.4", "@react-three/fiber": "8.0.12", "@tanstack/react-table": "^8.19.3", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 632ce964cd..95d3897285 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@codemirror/view': specifier: ^6.28.5 version: 6.28.5 + '@dnd-kit/abstract': + specifier: ^0.0.5 + version: 0.0.5 + '@dnd-kit/react': + specifier: ^0.0.5 + version: 0.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-three/drei': specifier: ^9.108.4 version: 9.108.4(@react-three/fiber@8.0.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.139.2))(@types/react@18.3.10)(@types/three@0.149.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.139.2) @@ -567,6 +573,27 @@ packages: '@codemirror/view@6.29.0': resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==} + '@dnd-kit/abstract@0.0.5': + resolution: {integrity: sha512-1ANNxFkYqXgfUHrCZsjwrDbiCYJkneW738KnB7AIakq/Bg5f4C3M63UUzTmkX7Vbbngl9Cvnmw7/N8Nwp0dUlQ==} + + '@dnd-kit/collision@0.0.5': + resolution: {integrity: sha512-rh9JwZiGnaq5hCR1Z1X4eWsA/wQiSDaYJnwnzvkidlwQBLJMwIKK1cs3FGjYSWTCDrtQvt1mzb1fow4w/MM4EA==} + + '@dnd-kit/dom@0.0.5': + resolution: {integrity: sha512-0QyyOrxokMtuMPJh/qmTyINrEU0TmYbOLprw55wD6t1ftfZZQCpQE42etM25UlfrIGpy9zT4qcNh/yGPP+ebmA==} + + '@dnd-kit/geometry@0.0.5': + resolution: {integrity: sha512-aBKYXSTIDW55iMd9jDrLSaikoebGZyn80sGalPxwjGgFr2FyiNCBkSy7lePZ/lrRfNTy1vMEqixFkt0EH6593g==} + + '@dnd-kit/react@0.0.5': + resolution: {integrity: sha512-hpTmh3gEoMyhiXoUH+mqHPMETflfL5uwhD2V+rOYMpoRdCQLYovy5r251TY+avieb8d8J7YuIB1k8Hxoy9UJXA==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@dnd-kit/state@0.0.5': + resolution: {integrity: sha512-w+GE/FUhIAoJUuPBR8yWvpdT+85R0xJyWE6ImaNTGQlplCeraxkiLAvYFI46uD8ZcE9nRXl3zIyk7gFlC0tzrQ==} + '@emotion/babel-plugin@11.11.0': resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} @@ -1091,6 +1118,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@preact/signals-core@1.8.0': + resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} + '@react-aria/breadcrumbs@3.5.13': resolution: {integrity: sha512-G1Gqf/P6kVdfs94ovwP18fTWuIxadIQgHsXS08JEVcFVYMjb9YjqnEBaohUxD1tq2WldMbYw53ahQblT4NTG+g==} peerDependencies: @@ -5445,6 +5475,45 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@dnd-kit/abstract@0.0.5': + dependencies: + '@dnd-kit/geometry': 0.0.5 + '@dnd-kit/state': 0.0.5 + tslib: 2.6.3 + + '@dnd-kit/collision@0.0.5': + dependencies: + '@dnd-kit/abstract': 0.0.5 + '@dnd-kit/geometry': 0.0.5 + tslib: 2.6.3 + + '@dnd-kit/dom@0.0.5': + dependencies: + '@dnd-kit/abstract': 0.0.5 + '@dnd-kit/collision': 0.0.5 + '@dnd-kit/geometry': 0.0.5 + '@dnd-kit/state': 0.0.5 + tslib: 2.6.3 + + '@dnd-kit/geometry@0.0.5': + dependencies: + '@dnd-kit/state': 0.0.5 + tslib: 2.6.3 + + '@dnd-kit/react@0.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/abstract': 0.0.5 + '@dnd-kit/dom': 0.0.5 + '@dnd-kit/state': 0.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.6.3 + + '@dnd-kit/state@0.0.5': + dependencies: + '@preact/signals-core': 1.8.0 + tslib: 2.6.3 + '@emotion/babel-plugin@11.11.0': dependencies: '@babel/helper-module-imports': 7.24.7 @@ -5994,6 +6063,8 @@ snapshots: dependencies: playwright: 1.47.0 + '@preact/signals-core@1.8.0': {} + '@react-aria/breadcrumbs@3.5.13(react@18.3.1)': dependencies: '@react-aria/i18n': 3.11.1(react@18.3.1) diff --git a/app/src/components/dnd/DragHandle.tsx b/app/src/components/dnd/DragHandle.tsx new file mode 100644 index 0000000000..d63bfd047e --- /dev/null +++ b/app/src/components/dnd/DragHandle.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { css } from "@emotion/react"; + +function DragHandle() { + return ( + + ); +} + +// Use Ref forwarding for DragHandle +const _DragHandle = React.forwardRef(DragHandle); +export { _DragHandle as DragHandle }; diff --git a/app/src/components/dnd/helpers/move.ts b/app/src/components/dnd/helpers/move.ts new file mode 100644 index 0000000000..702264423f --- /dev/null +++ b/app/src/components/dnd/helpers/move.ts @@ -0,0 +1,208 @@ +/** + * Copy of @dnd-kit/utils for a needed fix + * TODO: switch to @dnd-kit/helpers once the fix is released + * @src https://github.com/clauderic/dnd-kit/blob/experimental/packages/helpers/src/move.ts + */ +import type { + DragDropEvents, + DragDropManager, + Draggable, + Droppable, + UniqueIdentifier, +} from "@dnd-kit/abstract"; + +/** + * Move an array item to a different position. Returns a new array with the item moved to the new position. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function arrayMove( + array: T, + from: number, + to: number +): T { + if (from === to) { + return array; + } + + const newArray = array.slice() as T; + newArray.splice(to, 0, newArray.splice(from, 1)[0]); + + return newArray; +} + +/** + * Move an array item to a different position. Returns a new array with the item moved to the new position. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function arraySwap( + array: T, + from: number, + to: number +): T { + if (from === to) { + return array; + } + + const newArray = array.slice() as T; + const item = newArray[from]; + + newArray[from] = newArray[to]; + newArray[to] = item; + + return newArray; +} + +type Items = UniqueIdentifier[] | { id: UniqueIdentifier }[]; + +function mutate< + T extends Items | Record, + U extends Draggable, + V extends Droppable, + W extends DragDropManager, +>( + items: T, + event: Parameters< + DragDropEvents["dragover"] | DragDropEvents["dragend"] + >[0], + mutation: typeof arrayMove | typeof arraySwap +): T { + const { source, target, canceled } = event.operation; + + if (!source || !target || canceled || source.id === target.id) { + if ("preventDefault" in event) event.preventDefault(); + return items; + } + + const findIndex = (item: Items[0], id: UniqueIdentifier) => + item === id || (typeof item === "object" && "id" in item && item.id === id); + + if (Array.isArray(items)) { + const sourceIndex = items.findIndex((item) => findIndex(item, source.id)); + const targetIndex = items.findIndex((item) => findIndex(item, target.id)); + + if (sourceIndex === -1 || targetIndex === -1) { + return items; + } + + // Reconcile optimistic updates + if (!canceled && "index" in source && typeof source.index === "number") { + const projectedSourceIndex = source.index; + + if (projectedSourceIndex !== sourceIndex) { + return mutation(items, sourceIndex, projectedSourceIndex); + } + } + + return mutation(items, sourceIndex, targetIndex); + } + + const entries = Object.entries(items); + + let sourceIndex = -1; + let sourceParent: UniqueIdentifier | undefined; + let targetIndex = -1; + let targetParent: UniqueIdentifier | undefined; + + for (const [id, children] of entries) { + if (sourceIndex === -1) { + sourceIndex = children.findIndex((item) => findIndex(item, source.id)); + + if (sourceIndex !== -1) { + sourceParent = id; + } + } + + if (targetIndex === -1) { + targetIndex = children.findIndex((item) => findIndex(item, target.id)); + + if (targetIndex !== -1) { + targetParent = id; + } + } + + if (sourceIndex !== -1 && targetIndex !== -1) { + break; + } + } + + if (!source.manager) return items; + + const { dragOperation } = source.manager; + const position = + dragOperation.shape?.current.center ?? dragOperation.position.current; + + if (targetParent == null) { + if (target.id in items) { + const insertionIndex = + target.shape && position.y > target.shape.center.y + ? items[target.id].length + : 0; + + // The target does not have any matching children, but appears to be a valid target + targetParent = target.id; + targetIndex = insertionIndex; + } + } + + if ( + sourceParent == null || + targetParent == null || + (sourceParent === targetParent && sourceIndex === targetIndex) + ) { + if ("preventDefault" in event) event.preventDefault(); + + return items; + } + + if (sourceParent === targetParent) { + return { + ...items, + [sourceParent]: mutation(items[sourceParent], sourceIndex, targetIndex), + }; + } + + const isBelowTarget = target.shape && position.y > target.shape.center.y; + const modifier = isBelowTarget ? 1 : 0; + const sourceItem = items[sourceParent][sourceIndex]; + + return { + ...items, + [sourceParent]: [ + ...items[sourceParent].slice(0, sourceIndex), + ...items[sourceParent].slice(sourceIndex + 1), + ], + [targetParent]: [ + ...items[targetParent].slice(0, targetIndex + modifier), + sourceItem, + ...items[targetParent].slice(targetIndex + modifier), + ], + }; +} + +export function move< + T extends Items | Record, + U extends Draggable, + V extends Droppable, + W extends DragDropManager, +>( + items: T, + event: Parameters< + DragDropEvents["dragover"] | DragDropEvents["dragend"] + >[0] +) { + return mutate(items, event, arrayMove); +} + +export function swap< + T extends Items | Record, + U extends Draggable, + V extends Droppable, + W extends DragDropManager, +>( + items: T, + event: Parameters< + DragDropEvents["dragover"] | DragDropEvents["dragend"] + >[0] +) { + return mutate(items, event, arraySwap); +} diff --git a/app/src/pages/playground/PlaygroundChatTemplate.tsx b/app/src/pages/playground/PlaygroundChatTemplate.tsx index 021d3f9516..15067fb679 100644 --- a/app/src/pages/playground/PlaygroundChatTemplate.tsx +++ b/app/src/pages/playground/PlaygroundChatTemplate.tsx @@ -1,10 +1,19 @@ import React, { PropsWithChildren } from "react"; +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"; +import { DragDropProvider } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; import { css } from "@emotion/react"; -import { Card, CardProps, TextArea } from "@arizeai/components"; +import { Card, TextArea } from "@arizeai/components"; +import { DragHandle } from "@phoenix/components/dnd/DragHandle"; +import { move } from "@phoenix/components/dnd/helpers/move"; import { usePlaygroundContext } from "@phoenix/contexts/PlaygroundContext"; import { useChatMessageStyles } from "@phoenix/hooks/useChatMessageStyles"; +import { + ChatMessage, + PlaygroundChatTemplate as PlaygroundChatTemplateType, +} from "@phoenix/store"; import { MessageRolePicker } from "./MessageRolePicker"; import { PlaygroundInstanceProps } from "./types"; @@ -12,103 +21,147 @@ import { PlaygroundInstanceProps } from "./types"; interface PlaygroundChatTemplateProps extends PlaygroundInstanceProps {} export function PlaygroundChatTemplate(props: PlaygroundChatTemplateProps) { const id = props.playgroundInstanceId; - // TODO: remove the hard coding of the first instance const instances = usePlaygroundContext((state) => state.instances); const updateInstance = usePlaygroundContext((state) => state.updateInstance); - const playground = instances.find((instance) => instance.id === id); - if (!playground) { + const playgroundInstance = instances.find((instance) => instance.id === id); + if (!playgroundInstance) { throw new Error(`Playground instance ${id} not found`); } - const { template } = playground; + const { template } = playgroundInstance; if (template.__type !== "chat") { throw new Error(`Invalid template type ${template.__type}`); } return ( -
    { + const newMessages = move(template.messages, event); + updateInstance({ + instanceId: id, + patch: { + template: { + __type: "chat", + messages: newMessages, + }, + }, + }); + }} + onDragEnd={(event) => { + const newMessages = move(template.messages, event); + updateInstance({ + instanceId: id, + patch: { + template: { + __type: "chat", + messages: newMessages, + }, + }, + }); + }} > - {template.messages.map((message, index) => { - return ( -
  • - { - updateInstance({ - instanceId: id, - patch: { - template: { - __type: "chat", - messages: template.messages.map((message, i) => - i === index ? { ...message, role } : message - ), - }, - }, - }); - }} - /> - } - > -
    -