diff --git a/packages/editor/package.json b/packages/editor/package.json index d123303c4f..4a8d3ba898 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -66,6 +66,7 @@ "prosemirror-codemark": "^0.4.2", "prosemirror-utils": "^1.2.2", "react-moveable": "^0.54.2", + "smooth-scroll-into-view-if-needed": "^2.0.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.9", diff --git a/packages/editor/src/core/components/editors/index.ts b/packages/editor/src/core/components/editors/index.ts index 03ada4f726..f23317e3e5 100644 --- a/packages/editor/src/core/components/editors/index.ts +++ b/packages/editor/src/core/components/editors/index.ts @@ -5,3 +5,4 @@ export * from "./editor-container"; export * from "./editor-content"; export * from "./editor-wrapper"; export * from "./read-only-editor-wrapper"; +export * from "./pi-chat-editor"; diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/editor.tsx b/packages/editor/src/core/components/editors/pi-chat-editor/editor.tsx new file mode 100644 index 0000000000..6139c40ccf --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/editor.tsx @@ -0,0 +1,200 @@ +import Document from "@tiptap/extension-document"; +import Mention, { MentionOptions } from "@tiptap/extension-mention"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import { + Editor, + EditorContent, + Extension, + mergeAttributes, + ReactNodeViewRenderer, + ReactRenderer, + useEditor, +} from "@tiptap/react"; +import MentionList from "./mention-list.js"; +import tippy from "tippy.js"; +import { IMentionHighlight } from "@/types/mention-suggestion.js"; +import { PiMentionNodeView } from "./mention-node-view.js"; +import { cn, getTrimmedHTML } from "@/helpers/common.js"; +import "./styles.css"; +import { EnterKeyExtension } from "./extensions/enter-key-extension.js"; + +interface IItem { + id: string; + label: string; + entity_name: string; + entity_identifier: string; + target: string; + redirect_uri: string; + name?: string; + project__identifier?: string; + sequence_id?: string; + title: string; + subTitle: string | undefined; +} + +export interface IMentions { + [key: string]: Partial[] | undefined; +} +type PiChatEditorProps = { + setEditorCommand?: (command: any) => void; + mentionSuggestions?: (query: string) => Promise; + handleSubmit?: (e?: any) => void; + editable?: boolean; + content?: string; +}; + +interface CustomMentionOptions extends MentionOptions { + mentionHighlights: () => Promise; + readonly?: boolean; +} +export const PiChatEditor = (props: PiChatEditorProps) => { + const { setEditorCommand, mentionSuggestions, editable = true, content = "

", handleSubmit } = props; + const editor = useEditor({ + editable, + extensions: [ + EnterKeyExtension(handleSubmit), + Document, + Paragraph, + Text, + Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(PiMentionNodeView); + }, + parseHTML() { + return [ + { + tag: "mention-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, + }).configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: { + items: async ({ query }) => { + const response = await mentionSuggestions(query); + return response; + }, + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }, + }), + Paragraph.configure({ + HTMLAttributes: { + class: "text-[14px] leading-5 font-normal", + }, + }), + Extension.create({ + onUpdate(this) { + // The content has changed. + setEditorCommand({ + getHTML: () => getTrimmedHTML(this.editor?.getHTML()), + clear: () => this.editor?.commands.clearContent(), + }); + }, + }), + ], + content: content, + }); + + if (!editor) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/extensions/enter-key-extension.tsx b/packages/editor/src/core/components/editors/pi-chat-editor/extensions/enter-key-extension.tsx new file mode 100644 index 0000000000..a1086eb77c --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/extensions/enter-key-extension.tsx @@ -0,0 +1,27 @@ +import { Extension } from "@tiptap/core"; + +export const EnterKeyExtension = (onEnterKeyPress?: () => void) => + Extension.create({ + name: "enterKey", + + addKeyboardShortcuts(this) { + return { + Enter: () => { + if (!this.editor.storage.mentionsOpen) { + if (onEnterKeyPress) { + onEnterKeyPress(); + } + return true; + } + return false; + }, + "Shift-Enter": ({ editor }) => + editor.commands.first(({ commands }) => [ + () => commands.newlineInCode(), + () => commands.createParagraphNear(), + () => commands.liftEmptyBlock(), + () => commands.splitBlock(), + ]), + }; + }, + }); diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/index.ts b/packages/editor/src/core/components/editors/pi-chat-editor/index.ts new file mode 100644 index 0000000000..8b1fd904bb --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/index.ts @@ -0,0 +1 @@ +export * from "./editor"; diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/mention-list.tsx b/packages/editor/src/core/components/editors/pi-chat-editor/mention-list.tsx new file mode 100644 index 0000000000..d3b058c2aa --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/mention-list.tsx @@ -0,0 +1,199 @@ +import { cn } from "@/helpers/common"; +import { IMentionSuggestion } from "@/types"; +import { Disclosure, Transition } from "@headlessui/react"; +import { Editor } from "@tiptap/core"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; +import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; + +interface IItem { + id: string; + label: string; + entity_name: string; + entity_identifier: string; + target: string; + redirect_uri: string; + name?: string; + project__identifier?: string; + sequence_id?: string; + title?: string; + subTitle?: string; +} +interface MentionListProps { + command: (item: IItem) => void; + items: IItem; + query: string; + editor: Editor; + mentionSuggestions: () => Promise; +} +const suggestionTypes = ["issue", "page", "cycle", "module"]; + +type TSuggestion = { + type: string; + data: IItem[]; + key: number; + selectedIndex: number; + isSectionSelected: boolean; + onClick: (item: IItem, type: string) => void; +}; + +const Suggestions = ({ type, data, onClick, key, selectedIndex, isSectionSelected }: TSuggestion) => ( + <> + +
+
+ <> + {type} + +
+
+ + + {data && + data.map((d: IItem, index) => ( +
onClick(d, type)} + className={cn( + "rounded p-1 my-1 cursor-pointer hover:bg-custom-sidebar-background-80/50 text-xs font-medium text-custom-text-200 space-x-1 block", + { + "bg-custom-sidebar-background-80/50": selectedIndex === index && isSectionSelected, + } + )} + > + {type === "issue" && {d.subTitle}} + {d.title} +
+ ))} +
+
+
+ +); +const MentionList = forwardRef((props: MentionListProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedSection, setSelectedSection] = useState(0); + + const selectItem = (selectedItem: IItem, type: string) => { + try { + if (selectedItem) { + props.command({ + id: selectedItem.id, + label: type === "issue" ? `${selectedItem.subTitle}` : selectedItem.title, + entity_identifier: selectedItem.id, + entity_name: selectedItem.title, + target: `${type}s`, + redirect_uri: "", + }); + } + } catch (error) { + console.error("Error selecting item:", error); + } + }; + const handleNextSection = () => { + let nextSection = (selectedSection + 1) % suggestionTypes.length; + while (props.items[suggestionTypes[nextSection]].length === 0) { + nextSection = (nextSection + 1) % suggestionTypes.length; + } + setSelectedSection(nextSection); + }; + + const upHandler = () => { + if (selectedIndex === 0) { + setSelectedIndex(props.items[suggestionTypes[selectedSection]].length - 1); + let prevSection = (selectedSection - 1 + suggestionTypes.length) % suggestionTypes.length; + while (props.items[suggestionTypes[prevSection]].length === 0) { + prevSection = (prevSection - 1 + suggestionTypes.length) % suggestionTypes.length; + } + setSelectedSection(prevSection); + } else setSelectedIndex(selectedIndex - 1); + }; + + const downHandler = () => { + if (selectedIndex === props.items[suggestionTypes[selectedSection]].length - 1) { + setSelectedIndex(0); + handleNextSection(); + } else setSelectedIndex(selectedIndex + 1); + }; + + const enterHandler = () => { + selectItem(props.items[suggestionTypes[selectedSection]][selectedIndex], suggestionTypes[selectedSection]); + }; + + const tabHandler = () => { + handleNextSection(); + setSelectedIndex(0); + }; + + useEffect(() => { + setSelectedIndex(0); + setSelectedSection(0); + }, [props.items]); + + // Function to handle keydown events + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === "ArrowUp") { + upHandler(); + return true; + } + + if (event.key === "ArrowDown") { + console.log("down"); + downHandler(); + return true; + } + + if (event.key === "Enter") { + enterHandler(); + return true; + } + if (event.key === "Tab") { + tabHandler(); + return true; + } + + return false; + }, + })); + + const scrollIntoViewHelper = async (elementId: string) => { + const sourceElementId = elementId ?? ""; + const sourceElement = document.getElementById(sourceElementId); + if (sourceElement) + await smoothScrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); + }; + + useEffect(() => { + scrollIntoViewHelper(`${selectedSection}-${selectedIndex}`); + }, [selectedSection, selectedIndex]); + return ( +
+ {suggestionTypes.map((type, index) => ( + + ))} +
+ ); +}); + +export default MentionList; diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/mention-node-view.tsx b/packages/editor/src/core/components/editors/pi-chat-editor/mention-node-view.tsx new file mode 100644 index 0000000000..645264d4d3 --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/mention-node-view.tsx @@ -0,0 +1,43 @@ +// TODO: fix all warnings + +/* eslint-disable react/display-name */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { useEffect, useState } from "react"; +import { NodeViewWrapper } from "@tiptap/react"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { IMentionHighlight } from "@/types"; + +// eslint-disable-next-line import/no-anonymous-default-export +export const PiMentionNodeView = (props) => { + // TODO: move it to web app + const [highlightsState, setHighlightsState] = useState(); + + useEffect(() => { + if (!props.extension.options.mentionHighlights) return; + const hightlights = async () => { + const userId = await props.extension.options.mentionHighlights(); + setHighlightsState(userId); + }; + hightlights(); + }, [props.extension.options]); + + return ( + + + @{props.node.attrs.target} {props.node.attrs.label} + + + ); +}; diff --git a/packages/editor/src/core/components/editors/pi-chat-editor/styles.css b/packages/editor/src/core/components/editors/pi-chat-editor/styles.css new file mode 100644 index 0000000000..48e9f7cf86 --- /dev/null +++ b/packages/editor/src/core/components/editors/pi-chat-editor/styles.css @@ -0,0 +1,7 @@ +.tiptap p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } \ No newline at end of file diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 0fb32310d6..59ab4e0db2 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -43,8 +43,13 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null => }; export const getTrimmedHTML = (html: string) => { - html = html.replace(/^(

<\/p>)+/, ""); - html = html.replace(/(

<\/p>)+$/, ""); + // Trim leading/trailing whitespace + html = html.trim(); + + // Remove empty

tags at the start and end, accounting for any attributes in the

tag + html = html.replace(/^(]*><\/p>)+/, ""); + html = html.replace(/(]*><\/p>)+$/, ""); + return html; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ed7d913469..8f6fd837a6 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -15,6 +15,7 @@ export { LiteTextReadOnlyEditorWithRef, RichTextEditorWithRef, RichTextReadOnlyEditorWithRef, + PiChatEditor, } from "@/components/editors"; export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 4c8563f5f4..e734689c6a 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -26,6 +26,7 @@ module.exports = { theme: { extend: { boxShadow: { + "custom-shadow": "var(--color-shadow-custom)", "custom-shadow-2xs": "var(--color-shadow-2xs)", "custom-shadow-xs": "var(--color-shadow-xs)", "custom-shadow-sm": "var(--color-shadow-sm)", @@ -49,6 +50,19 @@ module.exports = { "onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)", }, colors: { + pi: { + 50: convertToRGB("--color-pi-50"), + 100: convertToRGB("--color-pi-100"), + 200: convertToRGB("--color-pi-200"), + 300: convertToRGB("--color-pi-300"), + 400: convertToRGB("--color-pi-400"), + 500: convertToRGB("--color-pi-500"), + 600: convertToRGB("--color-pi-600"), + 700: convertToRGB("--color-pi-700"), + 800: convertToRGB("--color-pi-800"), + 900: convertToRGB("--color-pi-900"), + 950: convertToRGB("--color-pi-950"), + }, custom: { primary: { 0: "rgb(255, 255, 255)", diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 4aca68be53..ae4a98d63f 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -71,18 +71,18 @@ export type TIssueMap = { type TIssueResponseResults = | TBaseIssue[] | { - [key: string]: { - results: - | TBaseIssue[] - | { - [key: string]: { - results: TBaseIssue[]; - total_results: number; - }; + [key: string]: { + results: + | TBaseIssue[] + | { + [key: string]: { + results: TBaseIssue[]; + total_results: number; + }; + }; + total_results: number; }; - total_results: number; }; - }; export type TIssuesResponse = { grouped_by: string; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 0440ff05f9..6e80df858d 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -3,7 +3,6 @@ import { TUserPermissions } from "./enums"; type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; - export interface IUserLite { avatar_url: string; display_name: string; @@ -28,6 +27,7 @@ export interface IUser extends IUserLite { username: string; last_login_medium: TLoginMediums; theme: IUserTheme; + avatar: string; } export interface IUserAccount { @@ -90,7 +90,6 @@ export interface IUserTheme { sidebarBackground: string | undefined; } - export interface IUserMemberLite extends IUserLite { email?: string; } @@ -153,7 +152,14 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: Pick & { + user_data: Pick< + IUser, + | "avatar_url" + | "cover_image_url" + | "display_name" + | "first_name" + | "last_name" + > & { date_joined: Date; user_timezone: string; }; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 79086ce9d0..3132e4c49d 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -35,3 +35,4 @@ export * from "./in-progress-icon"; export * from "./done-icon"; export * from "./pending-icon"; export * from "./pi-chat"; +export * from "./plane-ai-icon"; diff --git a/packages/ui/src/icons/plane-ai-icon.tsx b/packages/ui/src/icons/plane-ai-icon.tsx new file mode 100644 index 0000000000..54b85ae7f4 --- /dev/null +++ b/packages/ui/src/icons/plane-ai-icon.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const PlaneAIIcon: React.FC = ({ className = "", ...rest }) => ( + + + + + +); diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css index 79eb9e448a..0fda486f6a 100644 --- a/packages/ui/styles/globals.css +++ b/packages/ui/styles/globals.css @@ -101,6 +101,19 @@ --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* pi */ + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"], @@ -110,6 +123,18 @@ --color-background-100: 255, 255, 255; /* primary bg */ --color-background-90: 247, 247, 247; /* secondary bg */ --color-background-80: 232, 232, 232; /* tertiary bg */ + + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"] { @@ -167,6 +192,19 @@ --color-toast-warning-border: 255, 247, 194; --color-toast-info-border: 210, 222, 255; --color-toast-loading-border: 224, 225, 230; + + /* pi */ + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light-contrast"] { @@ -181,6 +219,19 @@ --color-border-200: 38, 38, 38; /* subtle border- 2 */ --color-border-300: 46, 46, 46; /* strong border- 1 */ --color-border-400: 58, 58, 58; /* strong border- 2 */ + + /* pi */ + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="dark"], @@ -200,6 +251,18 @@ --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="dark"] { @@ -256,6 +319,18 @@ --color-toast-warning-border: 79, 52, 34; --color-toast-info-border: 58, 91, 199; --color-toast-loading-border: 96, 100, 108; + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="dark-contrast"] { @@ -270,6 +345,18 @@ --color-border-200: 229, 229, 229; /* subtle border- 2 */ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="light"], diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx index 9207b9b49d..5de37a9a76 100644 --- a/web/app/[workspaceSlug]/(projects)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -3,6 +3,7 @@ import { CommandPalette } from "@/components/command-palette"; import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; import { AuthenticationWrapper } from "@/lib/wrappers"; +import { FloatingBot } from "@/plane-web/components/pi-chat"; import { AppSidebar } from "./sidebar"; export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { @@ -15,6 +16,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod

{children}
+ diff --git a/web/app/[workspaceSlug]/(projects)/pi-chat/layout.tsx b/web/app/[workspaceSlug]/(projects)/pi-chat/layout.tsx new file mode 100644 index 0000000000..3e38184767 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/pi-chat/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +// components +import { ContentWrapper } from "@/components/core"; + +export default function PiChatLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/pi-chat/page.tsx b/web/app/[workspaceSlug]/(projects)/pi-chat/page.tsx new file mode 100644 index 0000000000..3ca81aa474 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/pi-chat/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { PageHead } from "@/components/core"; +import { PiChatRoot } from "@/plane-web/components/pi-chat"; + +const PiChatPage = () => ( + <> + + + +); + +export default PiChatPage; diff --git a/web/core/components/issues/archive-issue-modal.tsx b/web/core/components/issues/archive-issue-modal.tsx index 4f944e26a0..beeea8c65c 100644 --- a/web/core/components/issues/archive-issue-modal.tsx +++ b/web/core/components/issues/archive-issue-modal.tsx @@ -9,6 +9,8 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // hooks import { useProject } from "@/hooks/store"; import { useIssues } from "@/hooks/store/use-issues"; +// ui +// types type Props = { data?: TIssue | TDeDupeIssue; diff --git a/web/core/components/issues/delete-issue-modal.tsx b/web/core/components/issues/delete-issue-modal.tsx index f191412347..2aeb653e8e 100644 --- a/web/core/components/issues/delete-issue-modal.tsx +++ b/web/core/components/issues/delete-issue-modal.tsx @@ -11,7 +11,6 @@ import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; import { useIssues, useProject, useUser, useUserPermissions } from "@/hooks/store"; // plane-web import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; - type Props = { isOpen: boolean; handleClose: () => void; diff --git a/web/core/components/issues/peek-overview/view.tsx b/web/core/components/issues/peek-overview/view.tsx index 653b1bc1f0..8a9a7f2e20 100644 --- a/web/core/components/issues/peek-overview/view.tsx +++ b/web/core/components/issues/peek-overview/view.tsx @@ -244,8 +244,9 @@ export const IssueView: FC = observer((props) => {
void; + label: string; +}; + +export const DeDupeButtonRoot: FC = (props) => { + const { workspaceSlug, isDuplicateModalOpen, label, handleOnClick } = props; + return ( + }> + + + ); +}; diff --git a/web/ee/components/de-dupe/duplicate-modal/block-header.tsx b/web/ee/components/de-dupe/duplicate-modal/block-header.tsx new file mode 100644 index 0000000000..7662e2523e --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-modal/block-header.tsx @@ -0,0 +1,43 @@ +"use-client"; +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { TDeDupeIssue } from "@plane/types"; +// hooks +import { useProject } from "@/hooks/store"; +// components +import { IssueIdentifier } from "@/plane-web/components/issues"; +// types + +type DuplicateIssueReadOnlyHeaderRoot = { + issue: TDeDupeIssue; +}; + +export const DuplicateIssueReadOnlyHeaderRoot: FC = observer((props) => { + const { issue } = props; + // store + const { getProjectById } = useProject(); + // derived values + const projectDetails = getProjectById(issue?.project_id); + const projectIdentifier = projectDetails?.identifier ?? ""; + + return ( + <> +
+
+ +
+
+ + ); +}); diff --git a/web/ee/components/de-dupe/duplicate-modal/block-root.tsx b/web/ee/components/de-dupe/duplicate-modal/block-root.tsx new file mode 100644 index 0000000000..6ae3b8d0a2 --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-modal/block-root.tsx @@ -0,0 +1,22 @@ +"use-client"; +import React, { FC } from "react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// local-components +import { DeDupeIssueBlockContent, DeDupeIssueBlockWrapper } from "../issue-block"; +import { DuplicateIssueReadOnlyHeaderRoot } from "./block-header"; + +type TDuplicateIssueReadOnlyBlockRootProps = { + workspaceSlug: string; + issue: TDeDupeIssue; +}; + +export const DuplicateIssueReadOnlyBlockRoot: FC = (props) => { + const { workspaceSlug, issue } = props; + return ( + + + + + ); +}; diff --git a/web/ee/components/de-dupe/duplicate-modal/index.ts b/web/ee/components/de-dupe/duplicate-modal/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-modal/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/components/de-dupe/duplicate-modal/root.tsx b/web/ee/components/de-dupe/duplicate-modal/root.tsx new file mode 100644 index 0000000000..8260449fa6 --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-modal/root.tsx @@ -0,0 +1,48 @@ +"use-client"; + +import { FC } from "react"; +import { X } from "lucide-react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// ui +import { PlaneAIIcon, Tooltip } from "@plane/ui"; +// local-components +import { DuplicateIssueReadOnlyBlockRoot } from "./block-root"; + +type TDuplicateModalRootProps = { + workspaceSlug: string; + issues: TDeDupeIssue[]; + handleDuplicateIssueModal: (value: boolean) => void; +}; + +export const DuplicateModalRoot: FC = (props) => { + const { workspaceSlug, issues, handleDuplicateIssueModal } = props; + return ( + <> +
+
+ Duplicate issues + + handleDuplicateIssueModal(false)} + /> + +
+
+ +

+ Below are the listed issues that seems to be similar or are duplicate of issue that you are trying to create +

+
+
+
+ <> + {issues.map((issue: TDeDupeIssue) => ( + + ))} + +
+ + ); +}; diff --git a/web/ee/components/de-dupe/duplicate-popover/block-header.tsx b/web/ee/components/de-dupe/duplicate-popover/block-header.tsx new file mode 100644 index 0000000000..799c70150e --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-popover/block-header.tsx @@ -0,0 +1,152 @@ +"use-client"; +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { Trash2 } from "lucide-react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// ui +import { ArchiveIcon, Checkbox, Tooltip } from "@plane/ui"; +// components +import { ArchiveIssueModal, DeleteIssueModal, TIssueOperations } from "@/components/issues"; +// constants +import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIssueDetail, useProject, useProjectState } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane-web +import { IssueIdentifier } from "@/plane-web/components/issues"; + +type TDeDupeIssueBlockHeaderProps = { + issue: TDeDupeIssue; + selectionHelpers: TSelectionHelper; + issueOperations?: TIssueOperations; + readOnly?: boolean; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssueBlockHeader: FC = observer((props) => { + const { issue, selectionHelpers, issueOperations, disabled = false, renderDeDupeActionModals, isIntakeIssue } = props; + // store + const { getStateById } = useProjectState(); + const { getProjectById } = useProject(); + const { isMobile } = usePlatformOS(); + const { isArchiveIssueModalOpen, toggleArchiveIssueModal, isDeleteIssueModalOpen, toggleDeleteIssueModal } = + useIssueDetail(); + // derived values + const stateDetails = issue ? getStateById(issue?.state_id) : undefined; + const isArchivingAllowed = !disabled; + const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); + const isSelected = selectionHelpers.getIsEntitySelected(issue.id); + const projectDetails = getProjectById(issue?.project_id); + const projectIdentifier = projectDetails?.identifier ?? ""; + + return ( + <> + {renderDeDupeActionModals && issue && isArchiveIssueModalOpen === issue.id && ( + toggleArchiveIssueModal(null)} + data={issue} + onSubmit={async () => { + if (projectDetails && issueOperations?.archive) + await issueOperations.archive(projectDetails?.workspace_detail?.slug, projectDetails?.id, issue.id); + }} + /> + )} + + {renderDeDupeActionModals && issue && isDeleteIssueModalOpen === issue.id && ( + { + toggleDeleteIssueModal(null); + }} + data={issue} + onSubmit={async () => { + if (projectDetails) + issueOperations?.remove(projectDetails?.workspace_detail?.slug, projectDetails.id, issue.id); + }} + /> + )} +
+
+ {!isIntakeIssue && ( + + )} + +
+
+ {isArchivingAllowed && ( + + + + )} + {!disabled && ( + + + + )} +
+
+ + ); +}); diff --git a/web/ee/components/de-dupe/duplicate-popover/block-root.tsx b/web/ee/components/de-dupe/duplicate-popover/block-root.tsx new file mode 100644 index 0000000000..0fdb62d6df --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-popover/block-root.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { FC } from "react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// components +import { TIssueOperations } from "@/components/issues"; +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +// local components +import { DeDupeIssueBlockContent, DeDupeIssueBlockWrapper } from "../issue-block"; +import { DeDupeIssueBlockHeader } from "./block-header"; + +type TDeDupeIssueBlockRootProps = { + workspaceSlug: string; + issue: TDeDupeIssue; + selectionHelpers: TSelectionHelper; + issueOperations?: TIssueOperations; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssueBlockRoot: FC = (props) => { + const { + workspaceSlug, + issue, + selectionHelpers, + issueOperations, + disabled = false, + renderDeDupeActionModals, + isIntakeIssue = false, + } = props; + // derived values + const isSelected = selectionHelpers.getIsEntitySelected(issue.id); + return ( + + + + + ); +}; diff --git a/web/ee/components/de-dupe/duplicate-popover/index.ts b/web/ee/components/de-dupe/duplicate-popover/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-popover/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/components/de-dupe/duplicate-popover/root.tsx b/web/ee/components/de-dupe/duplicate-popover/root.tsx new file mode 100644 index 0000000000..5075ee7c6b --- /dev/null +++ b/web/ee/components/de-dupe/duplicate-popover/root.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React, { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { usePopper } from "react-popper"; +import { Popover } from "@headlessui/react"; +// helpers +import { useOutsideClickDetector } from "@plane/helpers"; +// types +import { TDeDupeIssue } from "@plane/types"; +// ui +import { Button, setToast, TOAST_TYPE } from "@plane/ui"; +// components +import { MultipleSelectGroup } from "@/components/core"; +import { TIssueOperations } from "@/components/issues"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIssueDetail, useMultipleSelectStore } from "@/hooks/store"; +// plane-web +import { DeDupeIssueButtonLabel } from "@/plane-web/components/de-dupe"; +import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags"; +import { DE_DUPE_SELECT_GROUP } from "@/plane-web/constants/de-dupe"; +import { E_FEATURE_FLAGS } from "@/plane-web/hooks/store"; +// local-components +import { DeDupeIssueBlockRoot } from "./block-root"; + +type TDeDupeIssuePopoverRootProps = { + workspaceSlug: string; + projectId: string; + rootIssueId: string; + issues: TDeDupeIssue[]; + issueOperations: TIssueOperations; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssuePopoverRoot: FC = observer((props) => { + const { + workspaceSlug, + projectId, + rootIssueId, + issues, + issueOperations, + disabled = false, + renderDeDupeActionModals = true, + isIntakeIssue = false, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // refs + const containerRef = useRef(null); + // store + const { isArchiveIssueModalOpen, isDeleteIssueModalOpen, createRelation } = useIssueDetail(); + const { selectedEntityIds, clearSelection } = useMultipleSelectStore(); + // popper + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-end", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 20, + }, + }, + ], + }); + + const handleClose = () => { + setIsOpen(false); + clearSelection(); + }; + + const handleMarkAsDuplicate = async (workspaceSlug: string, projectId: string, issueId: string, data: string[]) => { + if (data.length === 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Please select at least one issue.", + }); + return; + } + + await createRelation(workspaceSlug, projectId, issueId, "duplicate", data); + + handleClose(); + }; + + useOutsideClickDetector(containerRef, () => { + if (isOpen && !isArchiveIssueModalOpen && !isDeleteIssueModalOpen) { + handleClose(); + } + }); + + const deDupeIds = issues.map((issue) => issue.id); + + if (!workspaceSlug || !projectId || !rootIssueId) return <>; + return ( + }> + + <> + + + + {isOpen && ( + +
+
+
+

+ {`Hey, ${issues?.length} issue${issues?.length > 1 ? "s" : ""} listed below seems to be duplicate of this issue.`} + Select if only some of them are helpful. +

+
+
+ + {(helpers) => ( + <> + {issues.map((issue: TDeDupeIssue) => ( + + ))} + + )} + +
+ {!isIntakeIssue && ( +
+ + +
+ )} +
+
+
+ )} + +
+
+ ); +}); diff --git a/web/ee/components/de-dupe/index.ts b/web/ee/components/de-dupe/index.ts index 1c66dae21d..91856db18e 100644 --- a/web/ee/components/de-dupe/index.ts +++ b/web/ee/components/de-dupe/index.ts @@ -1 +1,4 @@ -export * from "ce/components/de-dupe"; +export * from "./de-dupe-button"; +export * from "./duplicate-modal"; +export * from "./duplicate-popover"; +export * from "./issue-block"; diff --git a/web/ee/components/de-dupe/issue-block/block-content.tsx b/web/ee/components/de-dupe/issue-block/block-content.tsx new file mode 100644 index 0000000000..d66ebd6723 --- /dev/null +++ b/web/ee/components/de-dupe/issue-block/block-content.tsx @@ -0,0 +1,48 @@ +"use-client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// ui +import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; +// hooks +import { useMember, useProjectState } from "@/hooks/store"; + +type TDeDupeIssueBlockContentProps = { issue: TDeDupeIssue }; + +export const DeDupeIssueBlockContent: FC = observer((props) => { + const { issue } = props; + // store + const { getStateById } = useProjectState(); + const { + project: { getProjectMemberDetails }, + } = useMember(); + // derived values + const stateDetails = issue ? getStateById(issue?.state_id) : undefined; + const creator = getProjectMemberDetails(issue.created_by); + + return ( + <> +

+ {issue.name} +

+
+
+ +
+ + +

{stateDetails?.name ?? "State"}

+
+
+
+ +
+ + ); +}); diff --git a/web/ee/components/de-dupe/issue-block/block-wrapper.tsx b/web/ee/components/de-dupe/issue-block/block-wrapper.tsx new file mode 100644 index 0000000000..e9d7613a62 --- /dev/null +++ b/web/ee/components/de-dupe/issue-block/block-wrapper.tsx @@ -0,0 +1,38 @@ +"use-client"; + +import React, { FC } from "react"; +// types +import { TDeDupeIssue } from "@plane/types"; +// ui +import { ControlLink } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TDeDupeIssueBlockWrapperProps = { + workspaceSlug: string; + issue: TDeDupeIssue; + children: React.ReactNode; + isSelected?: boolean; +}; + +export const DeDupeIssueBlockWrapper: FC = (props) => { + const { workspaceSlug, issue, isSelected = false, children } = props; + // handlers + const handleRedirection = () => + window.open(`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`, "_blank"); + + return ( + + {children} + + ); +}; diff --git a/web/ee/components/de-dupe/issue-block/button-label.tsx b/web/ee/components/de-dupe/issue-block/button-label.tsx new file mode 100644 index 0000000000..ee353e055d --- /dev/null +++ b/web/ee/components/de-dupe/issue-block/button-label.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { FC } from "react"; +import { ChevronRight } from "lucide-react"; +// ui +import { PlaneAIIcon } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TDeDupeIssueButtonLabelProps = { + isOpen: boolean; + buttonLabel: string; +}; + +export const DeDupeIssueButtonLabel: FC = (props) => { + const { isOpen, buttonLabel } = props; + return ( +
+
+ +
+ +

{buttonLabel}

+
+ +
+ ); +}; diff --git a/web/ee/components/de-dupe/issue-block/index.ts b/web/ee/components/de-dupe/issue-block/index.ts new file mode 100644 index 0000000000..31cc5ee94d --- /dev/null +++ b/web/ee/components/de-dupe/issue-block/index.ts @@ -0,0 +1,3 @@ +export * from "./block-content"; +export * from "./block-wrapper"; +export * from "./button-label"; diff --git a/web/ee/components/pi-chat/base.tsx b/web/ee/components/pi-chat/base.tsx new file mode 100644 index 0000000000..4cc8f66526 --- /dev/null +++ b/web/ee/components/pi-chat/base.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { usePathname, useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { cn } from "@plane/editor"; +import { useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; +import { NewConversation, Messages } from "./conversation"; +import { Loading } from "./conversation/loading"; +import { Header } from "./header"; +import { History } from "./history"; +import { InputBox } from "./input"; + +export const PiChatBase = observer(() => { + const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); + + // store hooks + const { + initPiChat, + activeChatId, + isPiTyping, + isUserTyping, + activeChat, + fetchUserThreads, + geUserThreads, + fetchChatById, + isNewChat, + isLoading, + } = usePiChat(); + const { isMobile } = usePlatformOS(); + const { data: currentUser } = useUser(); + // router + const router = useAppRouter(); + // query params + const pathName = usePathname(); + const searchParams = useSearchParams(); + const chat_id = searchParams.get("chat_id"); + + // derived states + const userThreads = currentUser && geUserThreads(currentUser?.id); + const isFullScreen = pathName.includes("pi-chat"); + + useSWR( + currentUser ? `PI_USER_THREADS_${currentUser?.id}` : null, + currentUser ? () => fetchUserThreads(currentUser?.id) : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + } + ); + useSWR( + activeChatId && !isNewChat ? `PI_ACTIVE_CHAT_${activeChatId}` : null, + activeChatId && !isNewChat ? () => fetchChatById(activeChatId) : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + } + ); + + useEffect(() => { + initPiChat(chat_id || undefined); + }, [chat_id]); + + useEffect(() => { + if (!chat_id) router.push(`?chat_id=${activeChatId}`); + }, [activeChatId]); + + const toggleSidePanel = (value: boolean) => setIsSidePanelOpen(value); + + return ( +
+
+ {/* Header */} +
+
+
+ {/* New conversation */} + {activeChat?.dialogue?.length === 0 && isNewChat && } + + {/* Current conversation */} + {currentUser && activeChat?.dialogue?.length > 0 && !isLoading && ( + + )} + + {/* loading */} + {isLoading && !isNewChat && currentUser && ( + + )} +
+
+ {/* Chat Input */} + +
+ {/* History */} + {isFullScreen && ( + + )} +
+ ); +}); diff --git a/web/ee/components/pi-chat/conversation/ai-message.tsx b/web/ee/components/pi-chat/conversation/ai-message.tsx new file mode 100644 index 0000000000..7bcf7820d0 --- /dev/null +++ b/web/ee/components/pi-chat/conversation/ai-message.tsx @@ -0,0 +1,92 @@ +import Image from "next/image"; +import { Copy, ThumbsUp, ThumbsDown, Repeat2 } from "lucide-react"; +import { cn } from "@plane/editor"; +import { Loader, PiChatLogo, setToast, TOAST_TYPE } from "@plane/ui"; +import { copyTextToClipboard } from "@/helpers/string.helper"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; +import { EFeedback } from "@/plane-web/types"; +// import PiChatLogo from "@/public/logos/pi.png"; +import Typing from "./typing"; +type TProps = { + id: string; + message?: string; + isPiTyping?: boolean; + isLoading?: boolean; +}; +export const AiMessage = (props: TProps) => { + const { message = "", isPiTyping = false, id, isLoading = false } = props; + const { sendFeedback } = usePiChat(); + + const handleCopyLink = () => { + copyTextToClipboard(message).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Response copied!", + message: "Response to clipboard.", + }); + }); + }; + const handleFeedback = async (feedback: EFeedback) => { + try { + await sendFeedback(feedback); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Feedback sent!", + message: "Feedback sent!", + }); + } catch (e) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Feedback failed!", + message: "Feedback failed!", + }); + } + }; + return ( +
+ {/* Avatar */} +
+ +
+
+ {/* Message */} + {!isPiTyping && !isLoading &&
{message}
} + + {/* Typing */} + {isPiTyping && ( +
+ is thinking   + +
+ )} + + {isLoading && ( + + + + )} + {/* Action bar */} + {!isPiTyping && !isLoading && ( +
+ + handleFeedback(EFeedback.POSITIVE)} + className="my-auto cursor-pointer text-pi-700" + /> + handleFeedback(EFeedback.NEGATIVE)} + className="my-auto cursor-pointer text-pi-700" + /> + + {/* Rewrite will be available in the future */} + {/*
+ console.log()} className="my-auto cursor-pointer" /> Rewrite +
*/} +
+ )} +
+
+ ); +}; diff --git a/web/ee/components/pi-chat/conversation/index.ts b/web/ee/components/pi-chat/conversation/index.ts new file mode 100644 index 0000000000..be5ce22c7c --- /dev/null +++ b/web/ee/components/pi-chat/conversation/index.ts @@ -0,0 +1,4 @@ +export * from "./ai-message"; +export * from "./messages"; +export * from "./my-message"; +export * from "./new-converstaion"; diff --git a/web/ee/components/pi-chat/conversation/loading.tsx b/web/ee/components/pi-chat/conversation/loading.tsx new file mode 100644 index 0000000000..56ad4e549e --- /dev/null +++ b/web/ee/components/pi-chat/conversation/loading.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; +import { cn } from "@plane/editor"; +import { TChatHistory } from "@/plane-web/types"; +import { AiMessage } from "./ai-message"; +import { MyMessage } from "./my-message"; + +type TProps = { + isLoading: boolean; + isFullScreen: boolean; + currentUser: { + display_name: string; + avatar: string; + }; +}; + +export const Loading = observer((props: TProps) => { + const { isLoading, isFullScreen, currentUser } = props; + + return ( +
+ {/* Loading */} + {isLoading && } + {isLoading && } +
+ ); +}); diff --git a/web/ee/components/pi-chat/conversation/messages.tsx b/web/ee/components/pi-chat/conversation/messages.tsx new file mode 100644 index 0000000000..363520937b --- /dev/null +++ b/web/ee/components/pi-chat/conversation/messages.tsx @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; +import { cn } from "@plane/editor"; +import { TChatHistory } from "@/plane-web/types"; +import { AiMessage } from "./ai-message"; +import { MyMessage } from "./my-message"; + +type TProps = { + isPiTyping: boolean; + isUserTyping: boolean; + activeChat: TChatHistory | undefined; + isFullScreen: boolean; + isLoading: boolean; + currentUser: { + display_name: string; + avatar: string; + }; +}; + +export const Messages = observer((props: TProps) => { + const { isPiTyping, activeChat, currentUser, isUserTyping, isFullScreen, isLoading } = props; + + const scrollIntoViewHelper = async (elementId: string) => { + const sourceElementId = elementId ?? ""; + const sourceElement = document.getElementById(sourceElementId); + if (sourceElement) + await smoothScrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); + }; + + useEffect(() => { + //Always scroll to the latest message + if (!activeChat?.dialogue) return; + scrollIntoViewHelper((activeChat?.dialogue.length - 1).toString()); + }, [activeChat]); + + return ( +
+ {activeChat?.dialogue.map((message: string, index: number) => ( +
+ {index % 2 === 0 ? ( + + ) : ( + + )} +
+ ))} + {/* Typing */} + {isPiTyping && } + {isUserTyping && } + + {/* Loading */} + {isLoading && } + {isLoading && } +
+ ); +}); diff --git a/web/ee/components/pi-chat/conversation/my-message.tsx b/web/ee/components/pi-chat/conversation/my-message.tsx new file mode 100644 index 0000000000..c6b2237b76 --- /dev/null +++ b/web/ee/components/pi-chat/conversation/my-message.tsx @@ -0,0 +1,43 @@ +import { observer } from "mobx-react"; +import { cn, PiChatEditor } from "@plane/editor"; +import { Avatar, Card, Loader } from "@plane/ui"; +import Typing from "./typing"; + +type TProps = { + id: string; + isUserTyping?: boolean; + isLoading?: boolean; + message?: string; + currentUser: { + display_name: string; + avatar: string; + }; +}; +export const MyMessage = observer((props: TProps) => { + const { message, currentUser, isUserTyping = false, id, isLoading = false } = props; + + return ( +
+ {!isLoading && ( + + {/* Message */} + {!isUserTyping && } + {/* Typing */} + {isUserTyping && ( +
+ +
+ )} +
+ )} + {/* Loading */} + {isLoading && ( + + + + )} + {/* Avatar */} + +
+ ); +}); diff --git a/web/ee/components/pi-chat/conversation/new-converstaion.tsx b/web/ee/components/pi-chat/conversation/new-converstaion.tsx new file mode 100644 index 0000000000..ee8e26367e --- /dev/null +++ b/web/ee/components/pi-chat/conversation/new-converstaion.tsx @@ -0,0 +1,18 @@ +import { IUser } from "@plane/types"; +import SystemPrompts from "../system-prompts"; + +type TProps = { + currentUser: IUser | undefined; +}; +export const NewConversation = (props: TProps) => { + const { currentUser } = props; + + return ( +
+
Hey, {currentUser?.first_name}!
+
How can I help you today?
+ {/* Templates */} + +
+ ); +}; diff --git a/web/ee/components/pi-chat/conversation/typing.tsx b/web/ee/components/pi-chat/conversation/typing.tsx new file mode 100644 index 0000000000..0b6c87250b --- /dev/null +++ b/web/ee/components/pi-chat/conversation/typing.tsx @@ -0,0 +1,14 @@ +const Typing = () => ( + <> + + . + + + . + + + . + + +); +export default Typing; diff --git a/web/ee/components/pi-chat/floating-bot.tsx b/web/ee/components/pi-chat/floating-bot.tsx new file mode 100644 index 0000000000..acf0ce4a4d --- /dev/null +++ b/web/ee/components/pi-chat/floating-bot.tsx @@ -0,0 +1,56 @@ +import { useRef, useState } from "react"; +import { useParams, usePathname } from "next/navigation"; +import { X } from "lucide-react"; +import { cn } from "@plane/editor"; +import { useOutsideClickDetector } from "@plane/helpers"; +import { PiChatLogo } from "@plane/ui"; +import { WithFeatureFlagHOC } from "../feature-flags"; +import { PiChatBase } from "./base"; + +export const FloatingBot = () => { + // states + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + // query params + const pathName = usePathname(); + const { workspaceSlug } = useParams(); + + useOutsideClickDetector(ref, () => { + setIsOpen(false); + }); + + if (pathName.includes("pi-chat")) return null; + + return ( + }> +
+ {" "} + +
+ {isOpen && }{" "} +
+
+
+ ); +}; diff --git a/web/ee/components/pi-chat/header/header.tsx b/web/ee/components/pi-chat/header/header.tsx new file mode 100644 index 0000000000..6c46bb7886 --- /dev/null +++ b/web/ee/components/pi-chat/header/header.tsx @@ -0,0 +1,67 @@ +"use-client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { PanelRight, SquarePen } from "lucide-react"; +import { cn } from "@/helpers/common.helper"; +import { useAppRouter } from "@/hooks/use-app-router"; +import PiChatLogo from "@/public/logos/pi.png"; + +type THeaderProps = { + initPiChat: (chat_id?: string) => void; + isSidePanelOpen: boolean; + toggleSidePanel: (value: boolean) => void; + isFullScreen: boolean; +}; +export const Header = observer((props: THeaderProps) => { + const router = useAppRouter(); + const { initPiChat, isSidePanelOpen, toggleSidePanel, isFullScreen } = props; + const [isSearchOpen, setIsSearchOpen] = useState(false); + + const handleNewConversation = async () => { + const newChatId = initPiChat(); + router.push(`?chat_id=${newChatId}`); + }; + return ( +
+ {/* Breadcrumb */} +
+ Pi + Pi Chat +
+ {/* Actions */} + {!isSidePanelOpen && ( +
+ + + {isFullScreen && ( + + )} +
+ )} +
+ ); +}); diff --git a/web/ee/components/pi-chat/header/index.ts b/web/ee/components/pi-chat/header/index.ts new file mode 100644 index 0000000000..49ac70fe21 --- /dev/null +++ b/web/ee/components/pi-chat/header/index.ts @@ -0,0 +1 @@ +export * from "./header"; diff --git a/web/ee/components/pi-chat/helper.ts b/web/ee/components/pi-chat/helper.ts new file mode 100644 index 0000000000..c241cb662a --- /dev/null +++ b/web/ee/components/pi-chat/helper.ts @@ -0,0 +1,8 @@ +export const parseDataStream = (dataStream: string) => + // Split the input by newline and filter out lines that start with 'data: ' + dataStream + .split("\n") // Split input into lines + .filter((line) => line.startsWith("data: ")) // Keep only lines that start with 'data: ' + .map((line) => line.replace("data: ", "")) // Remove the 'data: ' prefix + .join("") // Join all characters into a single string + .replace("[DONE]", ""); diff --git a/web/ee/components/pi-chat/history/helper.ts b/web/ee/components/pi-chat/history/helper.ts new file mode 100644 index 0000000000..75b3967555 --- /dev/null +++ b/web/ee/components/pi-chat/history/helper.ts @@ -0,0 +1,38 @@ +import { TUserThreads } from "@/plane-web/types"; + +// Function to group threads by date ranges dynamically +export const groupThreadsByDate = (threads: TUserThreads[]) => { + const groupedThreads: Record = {}; + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + threads.forEach((thread) => { + const threadDate = new Date(thread.last_modified); + // Group by today + if (threadDate.toDateString() === today.toDateString()) { + if (!groupedThreads["today"]) { + groupedThreads["today"] = []; + } + groupedThreads["today"].push(thread); + } + // Group by yesterday + else if (threadDate.toDateString() === yesterday.toDateString()) { + if (!groupedThreads["yesterday"]) { + groupedThreads["yesterday"] = []; + } + groupedThreads["yesterday"].push(thread); + } + // Group by specific date (e.g., previous days) + else { + const dateKey = threadDate.toISOString().split("T")[0]; // Get date in YYYY-MM-DD format + if (!groupedThreads[dateKey]) { + groupedThreads[dateKey] = []; + } + groupedThreads[dateKey].push(thread); + } + }); + + return groupedThreads; +}; diff --git a/web/ee/components/pi-chat/history/index.ts b/web/ee/components/pi-chat/history/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/components/pi-chat/history/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/components/pi-chat/history/list.tsx b/web/ee/components/pi-chat/history/list.tsx new file mode 100644 index 0000000000..da9999aa1a --- /dev/null +++ b/web/ee/components/pi-chat/history/list.tsx @@ -0,0 +1,56 @@ +"use-client"; + +import { useParams } from "next/navigation"; +// ui +import { ControlLink } from "@plane/ui"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// plane-web +import { TUserThreads } from "@/plane-web/types"; +// local-components +import { groupThreadsByDate } from "./helper"; + +type TProps = { + userThreads: TUserThreads[]; +}; +const HistoryList = (props: TProps) => { + const { userThreads } = props; + // router + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + + // group threads by date + const groupedThreads: Record = groupThreadsByDate(userThreads); + + return ( +
+ {Object.entries(groupedThreads).map(([key, threads]) => ( +
+

+ {["today", "yesterday"].includes(key) ? key : renderFormattedDate(key)} +

+ +
+ {threads && threads.length > 0 ? ( + threads.map((thread) => ( + router.push(`?chat_id=${thread.chat_id}`)} + className="text-sm font-medium text-custom-text-300 text-left truncate p-2 rounded-lg hover:text-custom-text-200 hover:bg-custom-background-90" + > + {thread.title} + + )) + ) : ( +
No threads available
+ )} +
+
+ ))} +
+ ); +}; +export default HistoryList; diff --git a/web/ee/components/pi-chat/history/root.tsx b/web/ee/components/pi-chat/history/root.tsx new file mode 100644 index 0000000000..9f8365297c --- /dev/null +++ b/web/ee/components/pi-chat/history/root.tsx @@ -0,0 +1,55 @@ +"use-client"; + +import { useState } from "react"; +import { PanelRightClose } from "lucide-react"; +import { Card } from "@plane/ui"; +import { cn } from "@/helpers/common.helper"; +import { TUserThreads } from "@/plane-web/types"; +import HistoryList from "./list"; +import { Toolbar } from "./toolbar"; + +type TProps = { + userThreads: TUserThreads[] | undefined; + isSidePanelOpen: boolean; + isMobile?: boolean; + toggleSidePanel: (value: boolean) => void; + initPiChat: (chat_id?: string) => void; +}; +export const History = (props: TProps) => { + const { userThreads, isSidePanelOpen, toggleSidePanel, initPiChat, isMobile = false } = props; + // states + const [searchQuery, setSearchQuery] = useState(""); + // filter user threads + const filteredUserThread = + userThreads && userThreads.filter((thread) => thread.title.toLowerCase().includes(searchQuery.toLowerCase())); + // update search query + const updateSearchQuery = (value: string) => setSearchQuery(value); + + return ( + + {/* Header */} +
+
Chat history
+ +
+ + {/* Toolbar */} + + {/* History */} + +
+ ); +}; diff --git a/web/ee/components/pi-chat/history/toolbar.tsx b/web/ee/components/pi-chat/history/toolbar.tsx new file mode 100644 index 0000000000..8b4194826a --- /dev/null +++ b/web/ee/components/pi-chat/history/toolbar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Search, SquarePen, X } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { useAppRouter } from "@/hooks/use-app-router"; + +type Props = { + initPiChat: (chat_id?: string) => void; + searchQuery: string; + updateSearchQuery: (value: string) => void; +}; + +export const Toolbar: FC = observer((props) => { + const { initPiChat, searchQuery, updateSearchQuery } = props; + // refs + const inputRef = useRef(null); + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + + const router = useAppRouter(); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + const handleNewConversation = async () => { + const newChatId = initPiChat(); + router.push(`?chat_id=${newChatId}`); + }; + + return ( +
+ {/* New */} + + {/* Search */} +
+ {!isSearchOpen && ( + + )} + +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+
+ ); +}); diff --git a/web/ee/components/pi-chat/index.ts b/web/ee/components/pi-chat/index.ts new file mode 100644 index 0000000000..451adca5b6 --- /dev/null +++ b/web/ee/components/pi-chat/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./floating-bot"; diff --git a/web/ee/components/pi-chat/input/focus-filter.tsx b/web/ee/components/pi-chat/input/focus-filter.tsx new file mode 100644 index 0000000000..c65d1f2a8a --- /dev/null +++ b/web/ee/components/pi-chat/input/focus-filter.tsx @@ -0,0 +1,71 @@ +import { isEmpty } from "lodash"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ListFilter } from "lucide-react"; +import { CustomSelect, Logo } from "@plane/ui"; +import { WorkspaceLogo } from "@/components/workspace"; +import { useProject, useWorkspace } from "@/hooks/store"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; + +export const FocusFilter = observer(() => { + // router params + const { workspaceSlug } = useParams(); + + // store hooks + const { setFocus, currentFocus, activeChatId } = usePiChat(); + const { getWorkspaceBySlug } = useWorkspace(); + const { workspaceProjectIds, getProjectById } = useProject(); + + // derived values + const workspace = getWorkspaceBySlug(workspaceSlug as string); + const selectedFocus = + currentFocus?.entityType === "workspace_id" + ? workspace?.name + : getProjectById(currentFocus?.entityIdentifier)?.name; + + return ( + + + Focus + {!isEmpty(currentFocus) ? `: ${selectedFocus}` : ""} +
+ } + noChevron={!currentFocus} + onChange={(val: string) => { + setFocus(activeChatId, val.split("%")[0], val.split("%")[1]); + }} + maxHeight="lg" + className="flex flex-col-reverse" + buttonClassName="rounded-[28px] h-full px-2 bg-pi-100 border-none max-h-[36px]" + > + Ask Pi to use data from: + + + {workspace?.name} + + Projects + {workspaceProjectIds && + workspaceProjectIds.map((id) => { + const project = getProjectById(id); + return ( + +
+
{project && }
{" "} + {project?.name} +
+
+ ); + })} + + ); +}); diff --git a/web/ee/components/pi-chat/input/index.ts b/web/ee/components/pi-chat/input/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/components/pi-chat/input/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/components/pi-chat/input/root.tsx b/web/ee/components/pi-chat/input/root.tsx new file mode 100644 index 0000000000..9d014276ae --- /dev/null +++ b/web/ee/components/pi-chat/input/root.tsx @@ -0,0 +1,76 @@ +import { useCallback, useRef } from "react"; +import { useParams } from "next/navigation"; +import { ArrowUp } from "lucide-react"; +import { cn, PiChatEditor } from "@plane/editor"; +import { useUser } from "@/hooks/store"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; +import { FocusFilter } from "./focus-filter"; + +type TEditCommands = { + getHTML: () => string; + clear: () => void; +}; +type TProps = { + isFullScreen: boolean; +}; +export const InputBox = (props: TProps) => { + const { isFullScreen } = props; + // store hooks + const { getAnswer, searchCallback, isPiTyping } = usePiChat(); + const { data: currentUser } = useUser(); + const { workspaceSlug } = useParams(); + // states + const editorCommands = useRef(null); + const setEditorCommands = (command: TEditCommands) => { + editorCommands.current = command; + }; + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + if (!currentUser) return; + e?.preventDefault(); + const query = editorCommands.current?.getHTML(); + if (!query) return; + getAnswer(query, currentUser?.id); + editorCommands.current?.clear(); + }, + [currentUser, editorCommands, getAnswer] + ); + + return ( +
+
+ {/* Focus */} + + {/* Input Box */} + { + setEditorCommands({ ...command }); + }} + handleSubmit={handleSubmit} + mentionSuggestions={async (query: string) => searchCallback(workspaceSlug.toString(), query)} + /> + {/* Submit button */} + +
+
+ Pi can make mistakes, please double-check responses. +
+
+ ); +}; diff --git a/web/ee/components/pi-chat/root.tsx b/web/ee/components/pi-chat/root.tsx new file mode 100644 index 0000000000..a2777a2c56 --- /dev/null +++ b/web/ee/components/pi-chat/root.tsx @@ -0,0 +1,13 @@ +import { useParams } from "next/navigation"; +import { WithFeatureFlagHOC } from "../feature-flags"; +import { PiChatBase } from "./base"; + +export const PiChatRoot = () => { + const { workspaceSlug } = useParams(); + return ( + // Add CE component for fallback + }> + + + ); +}; diff --git a/web/ee/components/pi-chat/system-prompts.tsx b/web/ee/components/pi-chat/system-prompts.tsx new file mode 100644 index 0000000000..bc2184037b --- /dev/null +++ b/web/ee/components/pi-chat/system-prompts.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import useSWR from "swr"; +import { BriefcaseIcon, FileText } from "lucide-react"; +import { ContrastIcon, DiceIcon, LayersIcon, Loader, PiChatLogo } from "@plane/ui"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; + +type TSystemPrompt = { + userId: string | undefined; +}; +const SystemPrompts = (props: TSystemPrompt) => { + const { userId } = props; + // store hooks + const { getTemplates, startChatWithTemplate } = usePiChat(); + + const { data: templates } = useSWR("PI_TEMPLATES", () => getTemplates(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + + const getIcon = (type: string) => { + switch (type) { + case "pages": + return FileText; + case "cycles": + return ContrastIcon; + case "modules": + return DiceIcon; + case "projects": + return BriefcaseIcon; + case "issues": + return LayersIcon; + default: + return PiChatLogo; + } + }; + return ( +
+ {templates && userId ? ( + templates.map((prompt, index) => { + const promptIcon = getIcon(prompt.type); + + return ( +
startChatWithTemplate(prompt, userId)} + > + + {React.createElement(promptIcon, { + className: + prompt.type === "threads" + ? "size-[20px] text-pi-400 fill-current mb-2" + : `flex-shrink-0 size-[20px] stroke-[2] text-pi-400 stroke-current mb-2`, + })} + + {prompt.text} +
+ ); + }) + ) : ( + + + + + + )} +
+ ); +}; +export default SystemPrompts; diff --git a/web/ee/constants/dashboard.ts b/web/ee/constants/dashboard.ts new file mode 100644 index 0000000000..92588ed7d8 --- /dev/null +++ b/web/ee/constants/dashboard.ts @@ -0,0 +1,14 @@ +import { PiChatLogo } from "@plane/ui"; +import { SIDEBAR_USER_MENU_ITEMS } from "@/ce/constants/dashboard"; +import { EUserPermissions } from "./user-permissions"; + +SIDEBAR_USER_MENU_ITEMS.push({ + key: "pi-chat", + label: "Pi chat", + href: `/pi-chat`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/pi-chat/`), + Icon: PiChatLogo, +}); + +export { SIDEBAR_USER_MENU_ITEMS }; diff --git a/web/ee/constants/de-dupe.ts b/web/ee/constants/de-dupe.ts new file mode 100644 index 0000000000..a2e74cfa23 --- /dev/null +++ b/web/ee/constants/de-dupe.ts @@ -0,0 +1 @@ +export const DE_DUPE_SELECT_GROUP = "de-dupe-issues"; diff --git a/web/ee/hooks/store/use-flag.ts b/web/ee/hooks/store/use-flag.ts index d42d8837ca..ba5ddc3fa6 100644 --- a/web/ee/hooks/store/use-flag.ts +++ b/web/ee/hooks/store/use-flag.ts @@ -28,6 +28,9 @@ export enum E_FEATURE_FLAGS { SILO_INTEGRATION = "SILO_INTEGRATION", SILO_JIRA_INTEGRATION = "SILO_JIRA_INTEGRATION", SILO_LINEAR_INTEGRATION = "SILO_LINEAR_INTEGRATION", + // PI + PI_CHAT = "PI_CHAT", + PI_DEDUPE = "PI_DEDUPE", } export const useFlag = ( diff --git a/web/ee/hooks/store/use-pi-chat.ts b/web/ee/hooks/store/use-pi-chat.ts new file mode 100644 index 0000000000..f35ed2becc --- /dev/null +++ b/web/ee/hooks/store/use-pi-chat.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// context +import { StoreContext } from "@/lib/store-context"; +import { IPiChatStore } from "@/plane-web/store/pi-chat/pi-chat"; +// plane web stores + +export const usePiChat = (): IPiChatStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePiChat must be used within StoreProvider"); + return context.piChat; +}; diff --git a/web/ee/hooks/use-debounced-duplicate-issues.tsx b/web/ee/hooks/use-debounced-duplicate-issues.tsx index 5d421e2ca0..538566c0cc 100644 --- a/web/ee/hooks/use-debounced-duplicate-issues.tsx +++ b/web/ee/hooks/use-debounced-duplicate-issues.tsx @@ -1 +1,54 @@ -export * from "ce/hooks/use-debounced-duplicate-issues"; +import { useState, useEffect } from "react"; +import useSWR from "swr"; +// types +import { TDeDupeIssue } from "@plane/types"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; +// hooks +import useDebounce from "@/hooks/use-debounce"; +// services +import { PIService } from "@/plane-web/services"; + +const piService = new PIService(); + +export const useDebouncedDuplicateIssues = ( + workspaceId: string | undefined, + projectId: string | undefined, + formData: { name: string | undefined; description_html?: string | undefined; issueId?: string | undefined } +) => { + const [debouncedFormData, setDebouncedFormData] = useState(formData); + + // Debounce the name and description + const debouncedName = useDebounce(formData?.name, 3000); + const debouncedDescription = useDebounce(formData?.description_html, 3000); + + // Update debounced form data + useEffect(() => { + setDebouncedFormData({ + name: debouncedName, + description_html: debouncedDescription, + }); + }, [debouncedName, debouncedDescription]); + + const shouldFetch = workspaceId && projectId && debouncedFormData.name && debouncedFormData.name.trim() !== ""; + + // Fetch duplicate issues + const { data: issues } = useSWR( + shouldFetch ? `DUPLICATE_ISSUE_${workspaceId}_${projectId}_${debouncedFormData.name}` : null, + shouldFetch + ? async () => + await piService.getDuplicateIssues({ + workspace_id: workspaceId.toString(), + project_id: projectId, + issue_id: formData?.issueId, + title: debouncedFormData.name, + description_stripped: getTextContent(debouncedFormData.description_html), + }) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + const duplicateIssues: TDeDupeIssue[] = issues?.dupes ?? []; + + return { duplicateIssues }; +}; diff --git a/web/ee/services/index.ts b/web/ee/services/index.ts index 776efaccf3..3596bef8a1 100644 --- a/web/ee/services/index.ts +++ b/web/ee/services/index.ts @@ -3,3 +3,4 @@ export * from "./workspace.service"; export * from "./workspace-worklog.service"; export * from "./workspace-feature.service"; export * from "./workspace-project-states.service"; +export * from "./pi.service"; diff --git a/web/ee/services/pi-chat.service.ts b/web/ee/services/pi-chat.service.ts new file mode 100644 index 0000000000..b9bb1e6f61 --- /dev/null +++ b/web/ee/services/pi-chat.service.ts @@ -0,0 +1,100 @@ +// helpers +import { PI_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; +import { TFeedback, TQuery, TSearchQuery, TTemplate, TChatHistory, TUserThreads } from "../types"; + +type TTemplateResponse = { + templates: TTemplate[]; +}; +type TChatHistoryResponse = { + results: TChatHistory; +}; +type TUserThreadsResponse = { + results: TUserThreads[]; +}; +type TTitleResponse = { + title: string; +}; +type TPlaceholderResponse = { + placeholder: string; +}; +export class PiChatService extends APIService { + constructor() { + super(PI_BASE_URL); + } + + // fetch answer + async getAnswer(data: TQuery): Promise { + const r = await this.post(`/api/v1/chat/get-answer/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + + return r; + } + + // fetch templates + async getTemplate(): Promise { + return this.get(`/api/v1/chat/get-templates/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // generate title + async getTitle(chatId: string): Promise { + return this.post(`/api/v1/chat/generate-title/`, { chat_id: chatId }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // search chats + async searchChats(data: TSearchQuery): Promise { + return this.post(`/api/v1/chat/search-chats/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // get placeholder + async getPlaceHolder(data: TTemplate): Promise { + return this.post(`/api/v1/chat/get-placeholder/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // post feedback + async postFeedback(data: TFeedback): Promise { + return this.post(`/api/v1/chat/feedback/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // get chat by id + async getChatById(chatId: string): Promise { + return this.post(`/api/v1/chat/get-chat-history/`, { chat_id: chatId }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // get user threads + async getUserThreads(userId: string): Promise { + return this.post(`/api/v1/chat/get-user-threads/`, { user_id: userId }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/services/pi.service.ts b/web/ee/services/pi.service.ts new file mode 100644 index 0000000000..35dcedcf22 --- /dev/null +++ b/web/ee/services/pi.service.ts @@ -0,0 +1,19 @@ +// types +import { TDuplicateIssuePayload, TDuplicateIssueResponse } from "@plane/types"; +import { PI_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class PIService extends APIService { + constructor() { + super(PI_BASE_URL); + } + + async getDuplicateIssues(data: Partial): Promise { + return this.post(`/api/v1/dupes/issues/`, data) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } +} diff --git a/web/ee/services/workspace.service.ts b/web/ee/services/workspace.service.ts index a7ce09030d..e935e3c9ec 100644 --- a/web/ee/services/workspace.service.ts +++ b/web/ee/services/workspace.service.ts @@ -39,4 +39,10 @@ export class WorkspaceService extends CoreWorkspaceService { throw error?.response; }); } + + async searchAcrossWorkspace(workspaceSlug: string, params: { search: string; projectId?: string }) { + return this.get(`/api/workspaces/${workspaceSlug}/search/`, { + params, + }).then((response) => response?.data); + } } diff --git a/web/ee/store/pi-chat/helper.tsx b/web/ee/store/pi-chat/helper.tsx new file mode 100644 index 0000000000..bc1510c413 --- /dev/null +++ b/web/ee/store/pi-chat/helper.tsx @@ -0,0 +1,49 @@ +import { FileText } from "lucide-react"; +import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui"; + +interface IItem { + id: string; + label: string; + entity_name: string; + entity_identifier: string; + target: string; + redirect_uri: string; + name?: string; + project__identifier?: string; + sequence_id?: string; + title: string; + subTitle: string | undefined; +} + +export interface IFormattedValue { + [key: string]: Partial[] | undefined; +} +const getIcon = (type: string) => { + switch (type) { + case "cycle": + return ; + case "module": + return ; + case "page": + return ; + default: + return ; + } +}; +export const formatSearchQuery = (data: Partial): IFormattedValue => { + const parsedResponse: IFormattedValue = { + cycle: [], + module: [], + page: [], + issue: [], + }; + Object.keys(data).forEach((type) => { + parsedResponse[type] = data[type]?.slice(0, 5).map((item) => ({ + id: item.id, + title: item.name, + subTitle: type === "issue" ? `${item.project__identifier}-${item.sequence_id}` : undefined, + icon: getIcon(type), + })); + }); + return parsedResponse; +}; diff --git a/web/ee/store/pi-chat/pi-chat.ts b/web/ee/store/pi-chat/pi-chat.ts new file mode 100644 index 0000000000..c0503299ad --- /dev/null +++ b/web/ee/store/pi-chat/pi-chat.ts @@ -0,0 +1,280 @@ +import { set } from "lodash"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +import { v4 as uuidv4 } from "uuid"; +import { PI_BASE_URL } from "@/helpers/common.helper"; +import { WorkspaceService } from "@/plane-web/services"; +import { PiChatService } from "@/plane-web/services/pi-chat.service"; +import { RootStore } from "@/plane-web/store/root.store"; +import { EFeedback, TChatHistory, TFocus, TTemplate, TUserThreads } from "@/plane-web/types"; +import { formatSearchQuery, IFormattedValue } from "./helper"; + +export interface IPiChatStore { + isNewChat: boolean; + isLoading: boolean; + activeChatId: string; + activeChat: TChatHistory; + focus: Record; + isPiTyping: boolean; + isUserTyping: boolean; + threads: Record; // user -> threads + // computed fn + geUserThreads: (userId: string) => TUserThreads[]; + currentFocus: TFocus; + // actions + initPiChat: (chatId?: string) => string; + fetchChatById: (chatId: string) => void; + getAnswer: (query: string, user_id: string) => Promise; + getTemplates: () => Promise; + fetchUserThreads: (userId: string) => void; + searchCallback: (workspace: string, query: string) => Promise; + sendFeedback: (feedback: EFeedback) => Promise; + setFocus: (chatId: string, entityType: string, entityIdentifier: string) => void; + startChatWithTemplate: (template: TTemplate, userId: string) => Promise; +} + +export class PiChatStore implements IPiChatStore { + activeChatId = ""; + isNewChat: boolean = true; + isLoading: boolean = false; + isPiTyping = false; + isUserTyping = false; + activeChat: TChatHistory = { + chat_id: "", + dialogue: [], + title: "", + }; + focus: Record = {}; + threads: Record = {}; + + //services + piChatService; + workspaceService; + + constructor(public store: RootStore) { + makeObservable(this, { + //observables + isLoading: observable, + isNewChat: observable, + isPiTyping: observable, + isUserTyping: observable, + activeChatId: observable, + focus: observable, + activeChat: observable, + threads: observable, + // computed + currentFocus: computed, + // actions + initPiChat: action, + getAnswer: action, + getTemplates: action, + fetchUserThreads: action, + searchCallback: action, + sendFeedback: action, + setFocus: action, + startChatWithTemplate: action, + }); + + //services + this.piChatService = new PiChatService(); + this.workspaceService = new WorkspaceService(); + } + + get currentFocus() { + return this.focus[this.activeChatId]; + } + + setFocus = (chatId: string, entityType: string, entityIdentifier: string) => { + set(this.focus, [chatId], { entityType, entityIdentifier }); + }; + + // computed + geUserThreads = computedFn((userId: string) => { + if (!userId) return []; + return this.threads[userId]; + }); + + // actions + initPiChat = (chatId?: string) => { + this.activeChat = { + chat_id: "", + dialogue: [], + title: "", + }; + + // Existing chat + if (chatId) { + this.activeChatId = chatId; + this.isNewChat = false; + } + // New Chat + else { + // Generate new chat + const id = uuidv4(); + this.activeChatId = id; + this.isNewChat = true; + this.isLoading = false; + } + return this.activeChatId; + }; + + getTemplates = async () => { + const response = await this.piChatService.getTemplate(); + return response?.templates; + }; + + getAnswer = async (query: string, userId: string) => { + if (!this.activeChatId) { + throw new Error("Chat not initialized"); + } + const isNewChat = this.activeChat.dialogue.length === 0; + const userThreads = this.threads[userId]; + + // Optimistically update conversation with user query + runInAction(() => { + this.isPiTyping = true; + this.activeChat = { + ...this.activeChat, + dialogue: [...this.activeChat.dialogue, query], + }; + this.isUserTyping = false; + if (isNewChat) + set(this.threads, userId, [ + { + chat_id: this.activeChatId, + title: "New Chat", + last_modified: new Date().toISOString(), + }, + ...userThreads, + ]); + }); + + let payload = { + chat_id: this.activeChatId, + query, + is_new: true, + user_id: userId, + is_temp: false, + workspace_in_context: true, + }; + payload = this.currentFocus + ? { ...payload, [this.currentFocus.entityType]: this.currentFocus.entityIdentifier } + : payload; + + // Api call here + const response = await fetch(`${PI_BASE_URL}/api/v1/chat/get-answer/`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + // Parse code for a streaming response + if (!response.ok) throw new Error(await response.text()); + const reader = response?.body?.pipeThrough(new TextDecoderStream()).getReader(); + + const dialogueHistory = this.activeChat.dialogue; + let latestAiMessage = ""; + + while (true) { + const { done, value } = await (reader?.read() as Promise>); + if (this.isPiTyping) this.isPiTyping = false; + if (done) break; + if (value.startsWith("title: ")) continue; // Use this title value and remove the api call to get title in the future + + let formattedValue = value.replaceAll("data: ", ""); + formattedValue = formattedValue.replaceAll("\n", ""); + latestAiMessage += formattedValue; + + // Update the store with the latest ai message + runInAction(() => { + this.activeChat = { + ...this.activeChat, + dialogue: [...dialogueHistory, latestAiMessage], + }; + }); + } + + // Call the title api if its a new chat + if (isNewChat) { + const title = await this.piChatService.getTitle(this.activeChatId); + runInAction(() => { + set(this.threads, userId, [ + { + chat_id: this.activeChatId, + title: title.title, + last_modified: new Date().toISOString(), + }, + ...userThreads, + ]); + }); + } + + // Todo: Optimistically update the chat history + return latestAiMessage; + }; + + startChatWithTemplate = async (template: TTemplate, userId: string) => { + const placeholder = await this.piChatService.getPlaceHolder(template); + runInAction(() => { + this.isUserTyping = true; + }); + + await this.getAnswer(placeholder.placeholder, userId); + }; + + fetchChatById = async (chatId: string) => { + if (this.isNewChat) return; + try { + // Call api here + runInAction(() => { + this.isLoading = true; + }); + const response = await this.piChatService.getChatById(chatId); + + runInAction(() => { + this.activeChat = response.results; + this.isLoading = false; + this.isNewChat = response.results.dialogue.length === 0; + }); + } catch (e) { + console.log(e); + runInAction(() => { + this.isLoading = false; + this.isNewChat = true; + }); + } + }; + + fetchUserThreads = async (userId: string) => { + const response = await this.piChatService.getUserThreads(userId); + runInAction(() => { + set(this.threads, userId, response.results); + }); + }; + + searchCallback = async (workspace: string, search: string): Promise => { + const filteredProjectId = + this.focus[this.activeChatId]?.entityType === "project_id" + ? this.focus[this.activeChatId]?.entityIdentifier + : undefined; + let params: { search: string; projectId?: string } = { search }; + if (filteredProjectId) { + params = { + ...params, + projectId: filteredProjectId, + }; + } + const response = await this.workspaceService.searchAcrossWorkspace(workspace, params); + + return formatSearchQuery(response.results); + }; + + sendFeedback = async (feedback: EFeedback) => { + const response = await this.piChatService.postFeedback({ chat_id: this.activeChatId, feedback }); + return response; + }; +} diff --git a/web/ee/store/root.store.ts b/web/ee/store/root.store.ts index a0dc49d7f1..45671794fe 100644 --- a/web/ee/store/root.store.ts +++ b/web/ee/store/root.store.ts @@ -32,6 +32,7 @@ import { } from "@/plane-web/store/workspace-worklog"; // store import { CoreRootStore } from "@/store/root.store"; +import { IPiChatStore, PiChatStore } from "./pi-chat/pi-chat"; // import { ITimelineStore, TimeLineStore } from "./timeline"; @@ -49,6 +50,7 @@ export class RootStore extends CoreRootStore { issueTypes: IIssueTypesStore; issuePropertiesActivity: IIssuePropertiesActivityStore; cycle: ICycleStore; + piChat: IPiChatStore; timelineStore: ITimelineStore; constructor() { @@ -66,6 +68,7 @@ export class RootStore extends CoreRootStore { this.issuePropertiesActivity = new IssuePropertiesActivityStore(this); this.projectFilter = new ProjectFilterStore(this); this.cycle = new CycleStore(this); + this.piChat = new PiChatStore(this); this.timelineStore = new TimeLineStore(this); } @@ -84,5 +87,6 @@ export class RootStore extends CoreRootStore { this.issuePropertiesActivity = new IssuePropertiesActivityStore(this); this.projectFilter = new ProjectFilterStore(this); this.cycle = new CycleStore(this); + this.piChat = new PiChatStore(this); } } diff --git a/web/ee/types/index.ts b/web/ee/types/index.ts index 6840e8403c..0b94d1565a 100644 --- a/web/ee/types/index.ts +++ b/web/ee/types/index.ts @@ -5,4 +5,5 @@ export * from "./workspace.d"; export * from "./issue-types"; export * from "./workspace-worklog.d"; export * from "./cycles"; +export * from "./pi-chat.d"; export * from "./gantt-chart"; diff --git a/web/ee/types/pi-chat.d.ts b/web/ee/types/pi-chat.d.ts new file mode 100644 index 0000000000..b63f2c9ca8 --- /dev/null +++ b/web/ee/types/pi-chat.d.ts @@ -0,0 +1,52 @@ +export enum EFeedback { + POSITIVE = "positive", + NEGATIVE = "negative", +} + +export enum EChatType { + THREAD = "threads", +} +export type TQuery = { + query: string; + is_new: boolean; + user_id: string; + chat_id: string; + is_temp: boolean; + workspace_in_context: boolean; + workspace_id?: string; + project_id?: string; +}; + +export type TSearchQuery = { + query: string; + user_id: string; +}; + +export type TFeedback = { + chat_id: string; + feedback: EFeedback; +}; + +export type TFocus = { + entityType: string; + entityIdentifier: string; +}; + +export type TTemplate = { + text: string; + id: string[]; + type; +}; + +export type TChatHistory = { + chat_id: string; + dialogue: string[]; + title: string; +}; + +export type TUserThreads = { + chat_id: string; + dialogue: string[]; + title: string; + last_modified: string; +}; diff --git a/web/helpers/common.helper.ts b/web/helpers/common.helper.ts index fdb0cd6d0c..a8d03ea546 100644 --- a/web/helpers/common.helper.ts +++ b/web/helpers/common.helper.ts @@ -2,6 +2,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; +export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || ""; export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; diff --git a/web/public/logos/pi.png b/web/public/logos/pi.png new file mode 100644 index 0000000000..adef7c0a93 Binary files /dev/null and b/web/public/logos/pi.png differ diff --git a/web/styles/globals.css b/web/styles/globals.css index c218c045f6..09fe494edf 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -95,6 +95,7 @@ --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), 0px 1px 48px 0px rgba(16, 24, 40, 0.12); --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); + --color-shadow-custom: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ @@ -120,6 +121,18 @@ --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"], @@ -129,6 +142,18 @@ --color-background-100: 255, 255, 255; /* primary bg */ --color-background-90: 247, 247, 247; /* secondary bg */ --color-background-80: 232, 232, 232; /* tertiary bg */ + + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light"] { @@ -187,6 +212,18 @@ --color-toast-warning-border: 255, 247, 194; --color-toast-info-border: 210, 222, 255; --color-toast-loading-border: 224, 225, 230; + + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="light-contrast"] { @@ -202,6 +239,18 @@ --color-border-200: 38, 38, 38; /* subtle border- 2 */ --color-border-300: 46, 46, 46; /* strong border- 1 */ --color-border-400: 58, 58, 58; /* strong border- 2 */ + + --color-pi-50: 249, 249, 255; + --color-pi-100: 245, 245, 255; + --color-pi-200: 231, 231, 255; + --color-pi-300: 217, 216, 253; + --color-pi-400: 202, 201, 252; + --color-pi-500: 182, 181, 255; + --color-pi-600: 151, 150, 246; + --color-pi-700: 107, 106, 209; + --color-pi-800: 57, 56, 149; + --color-pi-900: 30, 29, 78; + --color-pi-950: 14, 14, 37; } [data-theme="dark"], @@ -230,6 +279,18 @@ --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="dark"] { @@ -287,6 +348,18 @@ --color-toast-warning-border: 79, 52, 34; --color-toast-info-border: 58, 91, 199; --color-toast-loading-border: 96, 100, 108; + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="dark-contrast"] { @@ -301,6 +374,18 @@ --color-border-200: 229, 229, 229; /* subtle border- 2 */ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-pi-50: 14, 14, 37; + --color-pi-100: 30, 29, 78; + --color-pi-200: 57, 56, 149; + --color-pi-300: 107, 106, 209; + --color-pi-400: 151, 150, 246; + --color-pi-500: 182, 181, 255; + --color-pi-600: 202, 201, 252; + --color-pi-700: 217, 216, 253; + --color-pi-800: 231, 231, 255; + --color-pi-900: 245, 245, 255; + --color-pi-950: 249, 249, 255; } [data-theme="light"], @@ -645,6 +730,14 @@ div.web-view-spinner div.bar12 { border: 3px solid rgba(0, 0, 0, 0); } +.shadow-custom { + box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */ +} +/* backdrop filter */ +.backdrop-blur-custom { + @apply backdrop-filter blur-[9px]; +} + /* scrollbar sm size */ .scrollbar-sm::-webkit-scrollbar { height: 12px;