mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
[PULSE-1, PULSE-9] feat: Pi Chat and Dedupes (#1627)
* [HACK-5] feat: de dupe implementation (#1600) * chore: plane ai icon added * chore: de-dupe services, constants and types added * chore: de-dupe constants and types updated * dev: de-dupe ui component added and implementation * chore: pi services and types updated * chore: useDebouncedDuplicateIssues hook added * chore: jsx to string helper function added * chore: useDebouncedDuplicateIssues implementation * chore-pi-color-variable * chore: pi service base url added * chore: pi endpoint updated * chore: pi endpoint updated * chore: pi endpoint updated * [HACK-5] chore: de dupe issue actions and code refactor (#1606) * chore: de-dupe action implementation and code refactoring * chore: code refactor * chore: pi color updated (#1608) * chore: structuring and UI components (#1596) * chore: structuring and UI components * fix: cleaning * fix: chat api integrations * fix: build issues * wip: pi editor * fix: integrated all apis * fix: integrational changes * fix: integrational changes * fix: build issues * fix: commented toolbar in history * fix: added streaming to pi messages * fix: scroll into view * chore: pi chat header and side panel improvements * fix: removed logs --------- Co-authored-by: Satish Gandham <satish.iitg@gmail.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so> * chore: pi chat history (#1615) * chore: structuring and UI components * fix: cleaning * fix: chat api integrations * fix: build issues * wip: pi editor * fix: integrated all apis * fix: integrational changes * fix: integrational changes * fix: build issues * fix: commented toolbar in history * fix: added streaming to pi messages * chore: pi chat header and side panel improvements * chore: pi chat history ui * chore: code refactor --------- Co-authored-by: gakshita <akshitagoyal1516@gmail.com> * Chore: pi improvement (#1619) * chore: de-dupe debounce time updated * chore: pi chat head title updated * chore: de-dupe button label and modal ux updated * fix: issue modal width * fix: pi-chat side panel * chore: added pi chat to side bar * chore: de-dupe modal --------- Co-authored-by: gakshita <akshitagoyal1516@gmail.com> * Fix build issue * chore: de-dupe modal theme (#1620) * fix: pi chat bug fixes and improvement (#1623) * fix: getAnswer error handling * fix: pi-chat layout and code refactor * fix: user message trim * chore: pi theme updated * chore: pi chat services update * chore: code refactor * chore: de-dupe modal transition added and height bug fixed (#1626) * chore: de dupe code splitting (#1628) * chore: code splitting * chore: code splitting * chore: code refactor * chore: code refactor * Fix: pi chat bug fixes and improvements (#1629) * fix: editor css + messages css * fix: submit on enter + mentions partial * fix: colors minor fixes * fix: build fixed * fix: enter key + editor css + shift key + feedback toast * fix: mentions dropdown untested * fix: css * fix: css * fix: build issues * fix: added latest conversation to history + layout fixing * fix: mentions key * fix: cn import in the pi editor * Fix build error * Fix pi chat api auth * chore: pi services endpoint updated * chore: code refactor * Fix floatin bot feature flag (#1651) * fix: floating bot + feature flagging * fix: minor fixes * fix: removed pi chat from community verison * fix: pi chat flag * chore de dupe flagging (#1657) * chore: de dupe feature flagging * chore: code refactor * chore: code refactor * chore: code refactor --------- Co-authored-by: Satish Gandham <satish.iitg@gmail.com> Co-authored-by: Pushya Mitra Thiruvooru <pushya@Pushyas-MacBook-Pro.local> Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
This commit is contained in:
committed by
GitHub
parent
dbb8af4ca4
commit
d787f9f79e
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<IItem>[] | undefined;
|
||||
}
|
||||
type PiChatEditorProps = {
|
||||
setEditorCommand?: (command: any) => void;
|
||||
mentionSuggestions?: (query: string) => Promise<any>;
|
||||
handleSubmit?: (e?: any) => void;
|
||||
editable?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
export const PiChatEditor = (props: PiChatEditorProps) => {
|
||||
const { setEditorCommand, mentionSuggestions, editable = true, content = "<p></p>", handleSubmit } = props;
|
||||
const editor = useEditor({
|
||||
editable,
|
||||
extensions: [
|
||||
EnterKeyExtension(handleSubmit),
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
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 (
|
||||
<div
|
||||
className={cn("w-full m-auto text-base", {
|
||||
"max-h-[185px] overflow-y-scroll": editable,
|
||||
})}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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(),
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./editor";
|
||||
@@ -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<IMentionSuggestion[]>;
|
||||
}
|
||||
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) => (
|
||||
<>
|
||||
<Disclosure as="div" className="flex flex-col py-2 ">
|
||||
<div className="flex">
|
||||
<div
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<span className="text-xs font-medium capitalize text-custom-text-300 my-1">{type}</span>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
show
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel as="div" className={cn("text-xs space-y-0 ml-0")} static>
|
||||
{data &&
|
||||
data.map((d: IItem, index) => (
|
||||
<div
|
||||
id={`${key}-${index}`}
|
||||
key={index}
|
||||
onClick={() => 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" && <span className="text-custom-sidebar-text-400">{d.subTitle}</span>}
|
||||
<span>{d.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
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 (
|
||||
<div className="w-[270px] border rounded p-2 divide-y-[1px] bg-custom-background-100 max-h-full overflow-y-scroll">
|
||||
{suggestionTypes.map((type, index) => (
|
||||
<Suggestions
|
||||
key={index}
|
||||
type={type}
|
||||
data={props.items[type]}
|
||||
onClick={selectItem}
|
||||
selectedIndex={selectedIndex}
|
||||
isSectionSelected={selectedSection === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MentionList;
|
||||
@@ -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<IMentionHighlight[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.extension.options.mentionHighlights) return;
|
||||
const hightlights = async () => {
|
||||
const userId = await props.extension.options.mentionHighlights();
|
||||
setHighlightsState(userId);
|
||||
};
|
||||
hightlights();
|
||||
}, [props.extension.options]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
<a
|
||||
href={props.node.attrs.redirect_uri}
|
||||
target="_blank"
|
||||
className={cn("mention rounded px-1 py-0.5 font-medium text-orange-500 bg-orange-100/40 text-base", {
|
||||
"bg-yellow-500/20 text-orange-500": highlightsState
|
||||
? highlightsState.includes(props.node.attrs.entity_identifier)
|
||||
: false,
|
||||
"cursor-pointer": !props.extension.options.readonly,
|
||||
})}
|
||||
>
|
||||
@{props.node.attrs.target} {props.node.attrs.label}
|
||||
</a>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -43,8 +43,13 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null =>
|
||||
};
|
||||
|
||||
export const getTrimmedHTML = (html: string) => {
|
||||
html = html.replace(/^(<p><\/p>)+/, "");
|
||||
html = html.replace(/(<p><\/p>)+$/, "");
|
||||
// Trim leading/trailing whitespace
|
||||
html = html.trim();
|
||||
|
||||
// Remove empty <p> tags at the start and end, accounting for any attributes in the <p> tag
|
||||
html = html.replace(/^(<p[^>]*><\/p>)+/, "");
|
||||
html = html.replace(/(<p[^>]*><\/p>)+$/, "");
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
LiteTextReadOnlyEditorWithRef,
|
||||
RichTextEditorWithRef,
|
||||
RichTextReadOnlyEditorWithRef,
|
||||
PiChatEditor,
|
||||
} from "@/components/editors";
|
||||
|
||||
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
@@ -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)",
|
||||
|
||||
20
packages/types/src/issues/issue.d.ts
vendored
20
packages/types/src/issues/issue.d.ts
vendored
@@ -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;
|
||||
|
||||
12
packages/types/src/users.d.ts
vendored
12
packages/types/src/users.d.ts
vendored
@@ -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<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
|
||||
user_data: Pick<
|
||||
IUser,
|
||||
| "avatar_url"
|
||||
| "cover_image_url"
|
||||
| "display_name"
|
||||
| "first_name"
|
||||
| "last_name"
|
||||
> & {
|
||||
date_joined: Date;
|
||||
user_timezone: string;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
14
packages/ui/src/icons/plane-ai-icon.tsx
Normal file
14
packages/ui/src/icons/plane-ai-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const PlaneAIIcon: React.FC<ISvgIcons> = ({ className = "", ...rest }) => (
|
||||
<svg viewBox="0 0 17 16" className={`${className}`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<path d="M3.83325 3C3.83325 2.44772 4.28097 2 4.83325 2H7.83325V14H3.83325V3Z" fill="#6B6AD1" />
|
||||
<path d="M8.33325 2H11.3333C11.8855 2 12.3333 2.44772 12.3333 3V9H8.33325V2Z" fill="#6B6AD1" />
|
||||
<path
|
||||
d="M10.1187 12.5375C10.0986 12.4596 10.058 12.3886 10.0012 12.3317C9.94429 12.2748 9.87323 12.2343 9.79537 12.2142L8.41503 11.8582C8.39148 11.8515 8.37075 11.8374 8.356 11.8178C8.34124 11.7983 8.33325 11.7745 8.33325 11.75C8.33325 11.7255 8.34124 11.7017 8.356 11.6822C8.37075 11.6626 8.39148 11.6485 8.41503 11.6418L9.79537 11.2856C9.87321 11.2655 9.94425 11.225 10.0011 11.1682C10.058 11.1114 10.0986 11.0403 10.1187 10.9625L10.4746 9.58218C10.4812 9.55854 10.4954 9.53771 10.515 9.52287C10.5345 9.50803 10.5584 9.5 10.583 9.5C10.6075 9.5 10.6314 9.50803 10.651 9.52287C10.6705 9.53771 10.6847 9.55854 10.6913 9.58218L11.047 10.9625C11.0671 11.0404 11.1077 11.1114 11.1645 11.1683C11.2214 11.2252 11.2925 11.2657 11.3703 11.2858L12.7507 11.6416C12.7744 11.6481 12.7953 11.6623 12.8103 11.6818C12.8252 11.7014 12.8333 11.7254 12.8333 11.75C12.8333 11.7746 12.8252 11.7986 12.8103 11.8182C12.7953 11.8377 12.7744 11.8519 12.7507 11.8584L11.3703 12.2142C11.2925 12.2343 11.2214 12.2748 11.1645 12.3317C11.1077 12.3886 11.0671 12.4596 11.047 12.5375L10.6911 13.9178C10.6845 13.9415 10.6703 13.9623 10.6507 13.9771C10.6312 13.992 10.6073 14 10.5827 14C10.5582 14 10.5343 13.992 10.5147 13.9771C10.4952 13.9623 10.481 13.9415 10.4744 13.9178L10.1187 12.5375Z"
|
||||
fill="#B6B5FF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
<FloatingBot />
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
|
||||
12
web/app/[workspaceSlug]/(projects)/pi-chat/layout.tsx
Normal file
12
web/app/[workspaceSlug]/(projects)/pi-chat/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
// components
|
||||
import { ContentWrapper } from "@/components/core";
|
||||
|
||||
export default function PiChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
web/app/[workspaceSlug]/(projects)/pi-chat/page.tsx
Normal file
13
web/app/[workspaceSlug]/(projects)/pi-chat/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { PageHead } from "@/components/core";
|
||||
import { PiChatRoot } from "@/plane-web/components/pi-chat";
|
||||
|
||||
const PiChatPage = () => (
|
||||
<>
|
||||
<PageHead title="Pi Chat" />
|
||||
<PiChatRoot />
|
||||
</>
|
||||
);
|
||||
|
||||
export default PiChatPage;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -244,8 +244,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${is_archived ? "pointer-events-none" : ""
|
||||
}`}
|
||||
className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${
|
||||
is_archived ? "pointer-events-none" : ""
|
||||
}`}
|
||||
>
|
||||
<PeekOverviewProperties
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
31
web/ee/components/de-dupe/de-dupe-button.tsx
Normal file
31
web/ee/components/de-dupe/de-dupe-button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
// constants
|
||||
import { E_FEATURE_FLAGS } from "@/plane-web/hooks/store";
|
||||
// local components
|
||||
import { WithFeatureFlagHOC } from "../feature-flags";
|
||||
import { DeDupeIssueButtonLabel } from "./issue-block";
|
||||
|
||||
type TDeDupeButtonRoot = {
|
||||
workspaceSlug: string;
|
||||
isDuplicateModalOpen: boolean;
|
||||
handleOnClick: () => void;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const DeDupeButtonRoot: FC<TDeDupeButtonRoot> = (props) => {
|
||||
const { workspaceSlug, isDuplicateModalOpen, label, handleOnClick } = props;
|
||||
return (
|
||||
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag={E_FEATURE_FLAGS.PI_DEDUPE} fallback={<></>}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleOnClick();
|
||||
}}
|
||||
>
|
||||
<DeDupeIssueButtonLabel isOpen={isDuplicateModalOpen} buttonLabel={label} />
|
||||
</button>
|
||||
</WithFeatureFlagHOC>
|
||||
);
|
||||
};
|
||||
43
web/ee/components/de-dupe/duplicate-modal/block-header.tsx
Normal file
43
web/ee/components/de-dupe/duplicate-modal/block-header.tsx
Normal file
@@ -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<DuplicateIssueReadOnlyHeaderRoot> = observer((props) => {
|
||||
const { issue } = props;
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(issue?.project_id);
|
||||
const projectIdentifier = projectDetails?.identifier ?? "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 ">
|
||||
<IssueIdentifier
|
||||
issueSequenceId={issue.sequence_id}
|
||||
projectIdentifier={projectIdentifier}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={issue.project_id}
|
||||
textContainerClassName="text-xs font-medium text-custom-text-300"
|
||||
size="xs"
|
||||
displayProperties={{
|
||||
key: true,
|
||||
issue_type: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
22
web/ee/components/de-dupe/duplicate-modal/block-root.tsx
Normal file
22
web/ee/components/de-dupe/duplicate-modal/block-root.tsx
Normal file
@@ -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<TDuplicateIssueReadOnlyBlockRootProps> = (props) => {
|
||||
const { workspaceSlug, issue } = props;
|
||||
return (
|
||||
<DeDupeIssueBlockWrapper workspaceSlug={workspaceSlug} issue={issue}>
|
||||
<DuplicateIssueReadOnlyHeaderRoot issue={issue} />
|
||||
<DeDupeIssueBlockContent issue={issue} />
|
||||
</DeDupeIssueBlockWrapper>
|
||||
);
|
||||
};
|
||||
1
web/ee/components/de-dupe/duplicate-modal/index.ts
Normal file
1
web/ee/components/de-dupe/duplicate-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
48
web/ee/components/de-dupe/duplicate-modal/root.tsx
Normal file
48
web/ee/components/de-dupe/duplicate-modal/root.tsx
Normal file
@@ -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<TDuplicateModalRootProps> = (props) => {
|
||||
const { workspaceSlug, issues, handleDuplicateIssueModal } = props;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-custom-text-300 font-semibold">Duplicate issues</span>
|
||||
<Tooltip tooltipContent="Close">
|
||||
<X
|
||||
className="cursor-pointer size-3.5 text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleDuplicateIssueModal(false)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-1.5 w-80 flex-shrink-0">
|
||||
<PlaneAIIcon className="size-4 flex-shrink-0" />
|
||||
<p className="text-left text-xs text-custom-text-200 flex-grow">
|
||||
Below are the listed issues that seems to be similar or are duplicate of issue that you are trying to create
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 overflow-hidden overflow-y-auto flex-grow pb-1 w-80">
|
||||
<>
|
||||
{issues.map((issue: TDeDupeIssue) => (
|
||||
<DuplicateIssueReadOnlyBlockRoot key={issue.id} workspaceSlug={workspaceSlug} issue={issue} />
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
152
web/ee/components/de-dupe/duplicate-popover/block-header.tsx
Normal file
152
web/ee/components/de-dupe/duplicate-popover/block-header.tsx
Normal file
@@ -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<TDeDupeIssueBlockHeaderProps> = 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 && (
|
||||
<ArchiveIssueModal
|
||||
isOpen={!!isArchiveIssueModalOpen}
|
||||
handleClose={() => 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 && (
|
||||
<DeleteIssueModal
|
||||
isOpen={!!isDeleteIssueModalOpen}
|
||||
handleClose={() => {
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issue}
|
||||
onSubmit={async () => {
|
||||
if (projectDetails)
|
||||
issueOperations?.remove(projectDetails?.workspace_detail?.slug, projectDetails.id, issue.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 ">
|
||||
{!isIntakeIssue && (
|
||||
<div
|
||||
className={cn(" hidden group-hover:block group-hover:pointer-events-auto transition-transform", {
|
||||
block: isSelected,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
selectionHelpers.handleEntityClick(e, issue.id, "de-dupe-issues");
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
className={cn("!outline-none size-3.5 pointer-events-none", {
|
||||
"pointer-events-auto text-white": isSelected,
|
||||
})}
|
||||
iconClassName="size-3"
|
||||
checked={isSelected}
|
||||
data-entity-id={issue.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<IssueIdentifier
|
||||
issueSequenceId={issue.sequence_id}
|
||||
projectIdentifier={projectIdentifier}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={issue.project_id}
|
||||
textContainerClassName="text-xs font-medium text-custom-text-300"
|
||||
size="xs"
|
||||
displayProperties={{
|
||||
key: true,
|
||||
issue_type: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isArchivingAllowed && (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("text-custom-text-300", {
|
||||
"hover:text-custom-text-200": isInArchivableGroup,
|
||||
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isInArchivableGroup) return;
|
||||
toggleArchiveIssueModal(issue.id);
|
||||
}}
|
||||
>
|
||||
<ArchiveIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!disabled && (
|
||||
<Tooltip tooltipContent="Delete" isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDeleteIssueModal(issue.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
49
web/ee/components/de-dupe/duplicate-popover/block-root.tsx
Normal file
49
web/ee/components/de-dupe/duplicate-popover/block-root.tsx
Normal file
@@ -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<TDeDupeIssueBlockRootProps> = (props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
issue,
|
||||
selectionHelpers,
|
||||
issueOperations,
|
||||
disabled = false,
|
||||
renderDeDupeActionModals,
|
||||
isIntakeIssue = false,
|
||||
} = props;
|
||||
// derived values
|
||||
const isSelected = selectionHelpers.getIsEntitySelected(issue.id);
|
||||
return (
|
||||
<DeDupeIssueBlockWrapper workspaceSlug={workspaceSlug} issue={issue} isSelected={isSelected}>
|
||||
<DeDupeIssueBlockHeader
|
||||
issue={issue}
|
||||
selectionHelpers={selectionHelpers}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disabled}
|
||||
renderDeDupeActionModals={renderDeDupeActionModals}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
/>
|
||||
<DeDupeIssueBlockContent issue={issue} />
|
||||
</DeDupeIssueBlockWrapper>
|
||||
);
|
||||
};
|
||||
1
web/ee/components/de-dupe/duplicate-popover/index.ts
Normal file
1
web/ee/components/de-dupe/duplicate-popover/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
190
web/ee/components/de-dupe/duplicate-popover/root.tsx
Normal file
190
web/ee/components/de-dupe/duplicate-popover/root.tsx
Normal file
@@ -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<TDeDupeIssuePopoverRootProps> = 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<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// refs
|
||||
const containerRef = useRef<HTMLTableElement | null>(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 (
|
||||
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag={E_FEATURE_FLAGS.PI_DEDUPE} fallback={<></>}>
|
||||
<Popover as="div" className={cn("relative")}>
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className={cn("outline-none")}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<DeDupeIssueButtonLabel isOpen={isOpen} buttonLabel="Potential duplicates found!" />
|
||||
</button>
|
||||
</Popover.Button>
|
||||
{isOpen && (
|
||||
<Popover.Panel className="fixed z-10" static>
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className={cn("mt-2 bg-custom-background-100 rounded-lg shadow-xl overflow-hidden")}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col gap-2.5 h-full px-3 py-4 rounded-lg shadow-xl bg-pi-50 max-h-[460px]"
|
||||
>
|
||||
<div className="flex gap-1.5 w-80 flex-shrink-0">
|
||||
<p className="text-left text-xs text-custom-text-200">
|
||||
{`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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 overflow-hidden overflow-y-auto flex-grow pb-1 w-80">
|
||||
<MultipleSelectGroup
|
||||
containerRef={containerRef}
|
||||
entities={{
|
||||
[DE_DUPE_SELECT_GROUP]: deDupeIds,
|
||||
}}
|
||||
>
|
||||
{(helpers) => (
|
||||
<>
|
||||
{issues.map((issue: TDeDupeIssue) => (
|
||||
<DeDupeIssueBlockRoot
|
||||
key={issue.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
issue={issue}
|
||||
selectionHelpers={helpers}
|
||||
issueOperations={issueOperations}
|
||||
renderDeDupeActionModals={renderDeDupeActionModals}
|
||||
isIntakeIssue={isIntakeIssue}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MultipleSelectGroup>
|
||||
</div>
|
||||
{!isIntakeIssue && (
|
||||
<div className="flex items-center gap-2 justify-end flex-shrink-0">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsDuplicate(workspaceSlug, projectId, rootIssueId, selectedEntityIds);
|
||||
}}
|
||||
>
|
||||
Mark as duplicate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
)}
|
||||
</>
|
||||
</Popover>
|
||||
</WithFeatureFlagHOC>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
48
web/ee/components/de-dupe/issue-block/block-content.tsx
Normal file
48
web/ee/components/de-dupe/issue-block/block-content.tsx
Normal file
@@ -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<TDeDupeIssueBlockContentProps> = 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 (
|
||||
<>
|
||||
<p className="w-full truncate cursor-pointer text-sm text-custom-text-100 pb-3 border-b border-custom-border-300 border-dashed">
|
||||
{issue.name}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityIcon priority={issue.priority} className="size-4" withContainer />
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<StateGroupIcon
|
||||
stateGroup={stateDetails?.group ?? "backlog"}
|
||||
color={stateDetails?.color ?? "rgba(var(--color-text-300))"}
|
||||
className="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span className="flex items-baseline">
|
||||
<p className="text-sm leading-3 text-custom-text-200 ">{stateDetails?.name ?? "State"}</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Avatar src={creator?.member.avatar_url} name={creator?.member.display_name} size="md" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
38
web/ee/components/de-dupe/issue-block/block-wrapper.tsx
Normal file
38
web/ee/components/de-dupe/issue-block/block-wrapper.tsx
Normal file
@@ -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<TDeDupeIssueBlockWrapperProps> = (props) => {
|
||||
const { workspaceSlug, issue, isSelected = false, children } = props;
|
||||
// handlers
|
||||
const handleRedirection = () =>
|
||||
window.open(`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`, "_blank");
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
className={cn(
|
||||
"group relative flex flex-col gap-3.5 w-80 rounded-lg px-3 py-2 bg-custom-background-100 border border-custom-primary-100/10",
|
||||
{
|
||||
"border-custom-primary-100/50 ": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={handleRedirection}
|
||||
>
|
||||
{children}
|
||||
</ControlLink>
|
||||
);
|
||||
};
|
||||
35
web/ee/components/de-dupe/issue-block/button-label.tsx
Normal file
35
web/ee/components/de-dupe/issue-block/button-label.tsx
Normal file
@@ -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<TDeDupeIssueButtonLabelProps> = (props) => {
|
||||
const { isOpen, buttonLabel } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-0.5 h-6 p-1 rounded border border-transparent bg-custom-primary-100/10 shadow-sm",
|
||||
{
|
||||
"border-custom-primary-100/10 shadow-md": isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<PlaneAIIcon className="size-4" />
|
||||
</div>
|
||||
<span className="flex items-baseline">
|
||||
<p className="text-sm text-custom-text-200">{buttonLabel}</p>
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-custom-text-400" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
web/ee/components/de-dupe/issue-block/index.ts
Normal file
3
web/ee/components/de-dupe/issue-block/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./block-content";
|
||||
export * from "./block-wrapper";
|
||||
export * from "./button-label";
|
||||
126
web/ee/components/pi-chat/base.tsx
Normal file
126
web/ee/components/pi-chat/base.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn("md:flex h-full bg-pi-50", {
|
||||
"md:w-[450px] max-w-[450px] max-h-[722px] shadow-2xl rounded-md z-[20]": !isFullScreen,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col flex-1 px-page-x pt-4 pb-8 relative h-full">
|
||||
{/* Header */}
|
||||
<Header
|
||||
initPiChat={initPiChat}
|
||||
isSidePanelOpen={isSidePanelOpen}
|
||||
toggleSidePanel={toggleSidePanel}
|
||||
isFullScreen={isFullScreen}
|
||||
/>
|
||||
<div className="flex flex-col h-full flex-1 align-middle justify-center max-w-[800px] md:m-auto w-full">
|
||||
<div className={cn("flex-1 my-auto flex flex-co h-full py-8 ", { "md:px-10": isFullScreen })}>
|
||||
{/* New conversation */}
|
||||
{activeChat?.dialogue?.length === 0 && isNewChat && <NewConversation currentUser={currentUser} />}
|
||||
|
||||
{/* Current conversation */}
|
||||
{currentUser && activeChat?.dialogue?.length > 0 && !isLoading && (
|
||||
<Messages
|
||||
isPiTyping={isPiTyping}
|
||||
activeChat={activeChat}
|
||||
currentUser={currentUser}
|
||||
isUserTyping={isUserTyping}
|
||||
isFullScreen={isFullScreen}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* loading */}
|
||||
{isLoading && !isNewChat && currentUser && (
|
||||
<Loading isLoading={isLoading} isFullScreen={isFullScreen} currentUser={currentUser} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat Input */}
|
||||
<InputBox isFullScreen={isFullScreen} />
|
||||
</div>
|
||||
{/* History */}
|
||||
{isFullScreen && (
|
||||
<History
|
||||
userThreads={userThreads}
|
||||
isSidePanelOpen={isSidePanelOpen}
|
||||
toggleSidePanel={toggleSidePanel}
|
||||
initPiChat={initPiChat}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
92
web/ee/components/pi-chat/conversation/ai-message.tsx
Normal file
92
web/ee/components/pi-chat/conversation/ai-message.tsx
Normal file
@@ -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 (
|
||||
<div className="flex gap-4" id={id}>
|
||||
{/* Avatar */}
|
||||
<div className="bg-gradient-to-br from-pi-100 to-pi-200 rounded-full h-9 w-9 flex">
|
||||
<PiChatLogo className="size-6 text-pi-700 fill-current m-auto align-center" />
|
||||
</div>
|
||||
<div className="flex flex-col w-fit">
|
||||
{/* Message */}
|
||||
{!isPiTyping && !isLoading && <div className={cn("text-base", {})}>{message}</div>}
|
||||
|
||||
{/* Typing */}
|
||||
{isPiTyping && (
|
||||
<div className="flex">
|
||||
<span className="text-base">is thinking </span>
|
||||
<Typing />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Loader>
|
||||
<Loader.Item width="50px" height="42px" />
|
||||
</Loader>
|
||||
)}
|
||||
{/* Action bar */}
|
||||
{!isPiTyping && !isLoading && (
|
||||
<div className="flex gap-4 mt-6">
|
||||
<Copy size={16} onClick={handleCopyLink} className="my-auto cursor-pointer text-pi-700" />
|
||||
<ThumbsUp
|
||||
size={16}
|
||||
onClick={() => handleFeedback(EFeedback.POSITIVE)}
|
||||
className="my-auto cursor-pointer text-pi-700"
|
||||
/>
|
||||
<ThumbsDown
|
||||
size={16}
|
||||
onClick={() => handleFeedback(EFeedback.NEGATIVE)}
|
||||
className="my-auto cursor-pointer text-pi-700"
|
||||
/>
|
||||
|
||||
{/* Rewrite will be available in the future */}
|
||||
{/* <div className="flex text-sm font-medium gap-1 cursor-pointer">
|
||||
<Repeat2 size={20} onClick={() => console.log()} className="my-auto cursor-pointer" /> Rewrite
|
||||
</div> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
web/ee/components/pi-chat/conversation/index.ts
Normal file
4
web/ee/components/pi-chat/conversation/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./ai-message";
|
||||
export * from "./messages";
|
||||
export * from "./my-message";
|
||||
export * from "./new-converstaion";
|
||||
32
web/ee/components/pi-chat/conversation/loading.tsx
Normal file
32
web/ee/components/pi-chat/conversation/loading.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn("flex flex-col gap-8 max-h-full h-full overflow-y-scroll w-[90%] mx-auto pb-[230px]", {
|
||||
"md:w-[70%]": isFullScreen,
|
||||
})}
|
||||
>
|
||||
{/* Loading */}
|
||||
{isLoading && <MyMessage isLoading={isLoading} currentUser={currentUser} id={""} />}
|
||||
{isLoading && <AiMessage isLoading={isLoading} id={""} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
61
web/ee/components/pi-chat/conversation/messages.tsx
Normal file
61
web/ee/components/pi-chat/conversation/messages.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn("flex flex-col gap-8 max-h-full h-full overflow-y-scroll w-[90%] mx-auto pb-[230px]", {
|
||||
"md:w-[70%]": isFullScreen,
|
||||
})}
|
||||
>
|
||||
{activeChat?.dialogue.map((message: string, index: number) => (
|
||||
<div key={index}>
|
||||
{index % 2 === 0 ? (
|
||||
<MyMessage message={message} currentUser={currentUser} id={index.toString()} />
|
||||
) : (
|
||||
<AiMessage message={message} id={index.toString()} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Typing */}
|
||||
{isPiTyping && <AiMessage isPiTyping={isPiTyping} id={""} />}
|
||||
{isUserTyping && <MyMessage isUserTyping={isUserTyping} currentUser={currentUser} id={""} />}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && <AiMessage isLoading={isLoading} id={""} />}
|
||||
{isLoading && <MyMessage isLoading={isLoading} currentUser={currentUser} id={""} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
43
web/ee/components/pi-chat/conversation/my-message.tsx
Normal file
43
web/ee/components/pi-chat/conversation/my-message.tsx
Normal file
@@ -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 (
|
||||
<div className="ml-auto mr-0 w-fit flex gap-2" id={id}>
|
||||
{!isLoading && (
|
||||
<Card className={cn("px-4 py-3 pr-10 w-fit text-base rounded-lg shadow-sm bg-custom-background-100", {})}>
|
||||
{/* Message */}
|
||||
{!isUserTyping && <PiChatEditor editable={false} content={message} />}
|
||||
{/* Typing */}
|
||||
{isUserTyping && (
|
||||
<div className="flex gap-2">
|
||||
<Typing />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<Loader>
|
||||
<Loader.Item width="50px" height="42px" />
|
||||
</Loader>
|
||||
)}
|
||||
{/* Avatar */}
|
||||
<Avatar size="lg" name={currentUser?.display_name} src={currentUser?.avatar} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
18
web/ee/components/pi-chat/conversation/new-converstaion.tsx
Normal file
18
web/ee/components/pi-chat/conversation/new-converstaion.tsx
Normal file
@@ -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 (
|
||||
<div className="m-auto">
|
||||
<div className="text-center text-3xl font-bold text-gray-300">Hey, {currentUser?.first_name}! </div>
|
||||
<div className="text-center text-2xl font-semibold text-slate-600">How can I help you today?</div>
|
||||
{/* Templates */}
|
||||
<SystemPrompts userId={currentUser?.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
web/ee/components/pi-chat/conversation/typing.tsx
Normal file
14
web/ee/components/pi-chat/conversation/typing.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
const Typing = () => (
|
||||
<>
|
||||
<span className="dot animate-bounce" style={{ animationDelay: "0s" }}>
|
||||
.
|
||||
</span>
|
||||
<span className="dot animate-bounce" style={{ animationDelay: "0.2s" }}>
|
||||
.
|
||||
</span>
|
||||
<span className="dot animate-bounce" style={{ animationDelay: "0.4s" }}>
|
||||
.
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
export default Typing;
|
||||
56
web/ee/components/pi-chat/floating-bot.tsx
Normal file
56
web/ee/components/pi-chat/floating-bot.tsx
Normal file
@@ -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 (
|
||||
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="PI_CHAT" fallback={<></>}>
|
||||
<div ref={ref}>
|
||||
{" "}
|
||||
<button
|
||||
className={cn("absolute bottom-4 right-4 bg-gradient-to-br rounded-full w-[40px] h-[40px] shadow-md z-[20]", {
|
||||
"from-pi-500 to-pi-700": !isOpen,
|
||||
"from-pi-200 to-pi-400": isOpen,
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsOpen((state) => !state);
|
||||
}}
|
||||
>
|
||||
{!isOpen ? (
|
||||
<PiChatLogo className="size-6 text-white fill-current m-auto align-center" />
|
||||
) : (
|
||||
<X className="size-6 text-white fill-current m-auto align-center" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-16 right-4 z-[20]",
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
isOpen ? "translate-y-[0%] h-[690px]" : "translate-y-[100%] h-0"
|
||||
)}
|
||||
>
|
||||
{isOpen && <PiChatBase />}{" "}
|
||||
</div>
|
||||
</div>
|
||||
</WithFeatureFlagHOC>
|
||||
);
|
||||
};
|
||||
67
web/ee/components/pi-chat/header/header.tsx
Normal file
67
web/ee/components/pi-chat/header/header.tsx
Normal file
@@ -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 (
|
||||
<div className="flex justify-between h-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex">
|
||||
<Image width={16} height={16} src={PiChatLogo} alt="Pi" className="my-auto" />
|
||||
<span className="font-medium text-sm my-auto"> Pi Chat</span>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{!isSidePanelOpen && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 h-8 w-8 rounded-lg shadow-sm bg-pi-100 border border-pi-200 transition-[width] ease-linear overflow-hidden",
|
||||
{
|
||||
"w-32": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
onMouseEnter={() => setIsSearchOpen(true)}
|
||||
onMouseLeave={() => setIsSearchOpen(false)}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<SquarePen className="flex-shrink-0 size-4 text-indigo-800" />
|
||||
{isSearchOpen && (
|
||||
<span className="text-custom-text-300 text-xs text-nowrap font-medium">Start a new chat</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isFullScreen && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-8 rounded-lg shadow-sm bg-custom-background-100 text-custom-text-400 hover:text-custom-text-200 border border-custom-border-100"
|
||||
onClick={() => toggleSidePanel(true)}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/pi-chat/header/index.ts
Normal file
1
web/ee/components/pi-chat/header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./header";
|
||||
8
web/ee/components/pi-chat/helper.ts
Normal file
8
web/ee/components/pi-chat/helper.ts
Normal file
@@ -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]", "");
|
||||
38
web/ee/components/pi-chat/history/helper.ts
Normal file
38
web/ee/components/pi-chat/history/helper.ts
Normal file
@@ -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<string, TUserThreads[]> = {};
|
||||
|
||||
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;
|
||||
};
|
||||
1
web/ee/components/pi-chat/history/index.ts
Normal file
1
web/ee/components/pi-chat/history/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
56
web/ee/components/pi-chat/history/list.tsx
Normal file
56
web/ee/components/pi-chat/history/list.tsx
Normal file
@@ -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<string, TUserThreads[]> = groupThreadsByDate(userThreads);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 space-y-2 overflow-hidden overflow-y-auto">
|
||||
{Object.entries(groupedThreads).map(([key, threads]) => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<h2 className="text-xs text-custom-text-400 font-medium capitalize">
|
||||
{["today", "yesterday"].includes(key) ? key : renderFormattedDate(key)}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{threads && threads.length > 0 ? (
|
||||
threads.map((thread) => (
|
||||
<ControlLink
|
||||
key={thread.chat_id}
|
||||
href={`/${workspaceSlug}/pi-chat?chat_id=${thread.chat_id}`}
|
||||
onClick={() => 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}
|
||||
</ControlLink>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-custom-text-400">No threads available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default HistoryList;
|
||||
55
web/ee/components/pi-chat/history/root.tsx
Normal file
55
web/ee/components/pi-chat/history/root.tsx
Normal file
@@ -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 (
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full text-base",
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
"shadow-lg z-20",
|
||||
isSidePanelOpen ? "translate-x-0 w-[260px]" : "translate-x-[100%] w-0",
|
||||
isMobile ? "fixed top-0 right-0 h-full" : "absolute right-0 top-0 md:relative"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between">
|
||||
<div className="text-sm text-custom-text-400 font-semibold">Chat history</div>
|
||||
<button
|
||||
className="text-custom-text-400 hover:text-custom-text-200 cursor-pointer"
|
||||
onClick={() => toggleSidePanel(false)}
|
||||
>
|
||||
<PanelRightClose className="size-4 " />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Toolbar initPiChat={initPiChat} searchQuery={searchQuery} updateSearchQuery={updateSearchQuery} />
|
||||
{/* History */}
|
||||
<HistoryList userThreads={filteredUserThread ?? []} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
102
web/ee/components/pi-chat/history/toolbar.tsx
Normal file
102
web/ee/components/pi-chat/history/toolbar.tsx
Normal file
@@ -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<Props> = observer((props) => {
|
||||
const { initPiChat, searchQuery, updateSearchQuery } = props;
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const router = useAppRouter();
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||
else setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewConversation = async () => {
|
||||
const newChatId = initPiChat();
|
||||
router.push(`?chat_id=${newChatId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 h-8 w-full">
|
||||
{/* New */}
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1 border border-pi-200 rounded-lg h-full w-8 bg-pi-100 text-custom-text-400 transition-[width] ease-linear overflow-hidden",
|
||||
{
|
||||
"w-full justify-center": !isSearchOpen,
|
||||
}
|
||||
)}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<SquarePen className="flex-shrink-0 size-4 text-indigo-800" />
|
||||
{!isSearchOpen && (
|
||||
<span className="text-custom-text-300 text-xs text-nowrap font-medium">Start a new chat</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Search */}
|
||||
<div className="flex items-center">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-8 rounded-lg shadow-sm bg-custom-background-100 text-custom-text-400 hover:text-custom-text-200 border border-custom-border-100"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-90 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
|
||||
{
|
||||
"w-full px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
2
web/ee/components/pi-chat/index.ts
Normal file
2
web/ee/components/pi-chat/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./root";
|
||||
export * from "./floating-bot";
|
||||
71
web/ee/components/pi-chat/input/focus-filter.tsx
Normal file
71
web/ee/components/pi-chat/input/focus-filter.tsx
Normal file
@@ -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 (
|
||||
<CustomSelect
|
||||
value={currentFocus}
|
||||
label={
|
||||
<div className="flex rounded-full gap-2">
|
||||
<ListFilter size={16} className="text-pi-500 my-auto" />
|
||||
<span className="text-sm font-medium text-custom-text-300 my-auto">Focus</span>
|
||||
<span className="text-sm my-auto">{!isEmpty(currentFocus) ? `: ${selectedFocus}` : ""}</span>
|
||||
</div>
|
||||
}
|
||||
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]"
|
||||
>
|
||||
<span className="text-custom-text-350 font-medium">Ask Pi to use data from:</span>
|
||||
<CustomSelect.Option
|
||||
value={`workspace_id%${workspace?.id}`}
|
||||
className="text-sm text-custom-text-200 font-medium flex justify-start"
|
||||
>
|
||||
<WorkspaceLogo logo={workspace?.logo_url} name={workspace?.name} classNames={"w-4 h-4 text-[9px]"} />
|
||||
<span>{workspace?.name}</span>
|
||||
</CustomSelect.Option>
|
||||
<span className="text-custom-text-350 font-medium">Projects</span>
|
||||
{workspaceProjectIds &&
|
||||
workspaceProjectIds.map((id) => {
|
||||
const project = getProjectById(id);
|
||||
return (
|
||||
<CustomSelect.Option
|
||||
key={id}
|
||||
value={`project_id%${id}`}
|
||||
className="text-sm text-custom-text-200 font-medium"
|
||||
>
|
||||
<div className="flex flex-start gap-2">
|
||||
<div className="size-4 m-auto">{project && <Logo logo={project?.logo_props} />}</div>{" "}
|
||||
<span>{project?.name}</span>
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/pi-chat/input/index.ts
Normal file
1
web/ee/components/pi-chat/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
76
web/ee/components/pi-chat/input/root.tsx
Normal file
76
web/ee/components/pi-chat/input/root.tsx
Normal file
@@ -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<TEditCommands | null>(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 (
|
||||
<form
|
||||
className={cn(
|
||||
"flex flex-col absolute bottom-3 inset-x-10 bg-pi-50 left-1/2 transform -translate-x-1/2 max-w-[800px] w-[90%] px-2 md:px-0",
|
||||
{
|
||||
"md:w-[70%]": isFullScreen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="bg-custom-background-100 w-full rounded-[28px] p-2 flex gap-3 shadow-sm border-[4px] border-pi-100">
|
||||
{/* Focus */}
|
||||
<FocusFilter />
|
||||
{/* Input Box */}
|
||||
<PiChatEditor
|
||||
setEditorCommand={(command) => {
|
||||
setEditorCommands({ ...command });
|
||||
}}
|
||||
handleSubmit={handleSubmit}
|
||||
mentionSuggestions={async (query: string) => searchCallback(workspaceSlug.toString(), query)}
|
||||
/>
|
||||
{/* Submit button */}
|
||||
<button
|
||||
className={cn("border p-2 my-auto mb-0 rounded-full bg-gradient-to-b from-pi-500 to-pi-600 text-white", {
|
||||
"from-pi-100 to-pi-300 text-pi-400 cursor-not-allowed": isPiTyping,
|
||||
})}
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPiTyping}
|
||||
>
|
||||
<ArrowUp size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-custom-text-350 mt-2 text-center">
|
||||
Pi can make mistakes, please double-check responses.
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
13
web/ee/components/pi-chat/root.tsx
Normal file
13
web/ee/components/pi-chat/root.tsx
Normal file
@@ -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
|
||||
<WithFeatureFlagHOC workspaceSlug={workspaceSlug?.toString()} flag="PI_CHAT" fallback={<></>}>
|
||||
<PiChatBase />
|
||||
</WithFeatureFlagHOC>
|
||||
);
|
||||
};
|
||||
71
web/ee/components/pi-chat/system-prompts.tsx
Normal file
71
web/ee/components/pi-chat/system-prompts.tsx
Normal file
@@ -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 (
|
||||
<div className="flex gap-6 max-[755px] flex-wrap m-auto justify-center mt-6">
|
||||
{templates && userId ? (
|
||||
templates.map((prompt, index) => {
|
||||
const promptIcon = getIcon(prompt.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-custom-background-100 rounded-lg flex flex-col w-[250px] p-4 border-none shadow-custom cursor-pointer"
|
||||
onClick={() => startChatWithTemplate(prompt, userId)}
|
||||
>
|
||||
<span>
|
||||
{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`,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-sm">{prompt.text}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="flex flex-wrap m-auto justify-center gap-6">
|
||||
<Loader.Item width="250px" height="90px" />
|
||||
<Loader.Item width="250px" height="90px" />
|
||||
<Loader.Item width="250px" height="90px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SystemPrompts;
|
||||
14
web/ee/constants/dashboard.ts
Normal file
14
web/ee/constants/dashboard.ts
Normal file
@@ -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 };
|
||||
1
web/ee/constants/de-dupe.ts
Normal file
1
web/ee/constants/de-dupe.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DE_DUPE_SELECT_GROUP = "de-dupe-issues";
|
||||
@@ -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 = (
|
||||
|
||||
11
web/ee/hooks/store/use-pi-chat.ts
Normal file
11
web/ee/hooks/store/use-pi-chat.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
100
web/ee/services/pi-chat.service.ts
Normal file
100
web/ee/services/pi-chat.service.ts
Normal file
@@ -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<string> {
|
||||
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<TTemplateResponse> {
|
||||
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<TTitleResponse> {
|
||||
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<void> {
|
||||
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<TPlaceholderResponse> {
|
||||
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<void> {
|
||||
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<TChatHistoryResponse> {
|
||||
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<TUserThreadsResponse> {
|
||||
return this.post(`/api/v1/chat/get-user-threads/`, { user_id: userId })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
19
web/ee/services/pi.service.ts
Normal file
19
web/ee/services/pi.service.ts
Normal file
@@ -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<TDuplicateIssuePayload>): Promise<TDuplicateIssueResponse> {
|
||||
return this.post(`/api/v1/dupes/issues/`, data)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
49
web/ee/store/pi-chat/helper.tsx
Normal file
49
web/ee/store/pi-chat/helper.tsx
Normal file
@@ -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<IItem>[] | undefined;
|
||||
}
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "cycle":
|
||||
return <ContrastIcon />;
|
||||
case "module":
|
||||
return <DiceIcon />;
|
||||
case "page":
|
||||
return <FileText />;
|
||||
default:
|
||||
return <LayersIcon />;
|
||||
}
|
||||
};
|
||||
export const formatSearchQuery = (data: Partial<IFormattedValue>): 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;
|
||||
};
|
||||
280
web/ee/store/pi-chat/pi-chat.ts
Normal file
280
web/ee/store/pi-chat/pi-chat.ts
Normal file
@@ -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<string, TFocus>;
|
||||
isPiTyping: boolean;
|
||||
isUserTyping: boolean;
|
||||
threads: Record<string, TUserThreads[]>; // 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<string>;
|
||||
getTemplates: () => Promise<TTemplate[]>;
|
||||
fetchUserThreads: (userId: string) => void;
|
||||
searchCallback: (workspace: string, query: string) => Promise<IFormattedValue>;
|
||||
sendFeedback: (feedback: EFeedback) => Promise<void>;
|
||||
setFocus: (chatId: string, entityType: string, entityIdentifier: string) => void;
|
||||
startChatWithTemplate: (template: TTemplate, userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class PiChatStore implements IPiChatStore {
|
||||
activeChatId = "";
|
||||
isNewChat: boolean = true;
|
||||
isLoading: boolean = false;
|
||||
isPiTyping = false;
|
||||
isUserTyping = false;
|
||||
activeChat: TChatHistory = {
|
||||
chat_id: "",
|
||||
dialogue: [],
|
||||
title: "",
|
||||
};
|
||||
focus: Record<string, TFocus> = {};
|
||||
threads: Record<string, TUserThreads[]> = {};
|
||||
|
||||
//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<ReadableStreamReadResult<string>>);
|
||||
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<IFormattedValue> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
52
web/ee/types/pi-chat.d.ts
vendored
Normal file
52
web/ee/types/pi-chat.d.ts
vendored
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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 || "";
|
||||
|
||||
BIN
web/public/logos/pi.png
Normal file
BIN
web/public/logos/pi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 B |
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user