[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:
Anmol Singh Bhatia
2024-11-05 19:39:28 +05:30
committed by GitHub
parent dbb8af4ca4
commit d787f9f79e
76 changed files with 3016 additions and 20 deletions

View File

@@ -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",

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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(),
]),
};
},
});

View File

@@ -0,0 +1 @@
export * from "./editor";

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -15,6 +15,7 @@ export {
LiteTextReadOnlyEditorWithRef,
RichTextEditorWithRef,
RichTextReadOnlyEditorWithRef,
PiChatEditor,
} from "@/components/editors";
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";

View File

@@ -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)",

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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";

View 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>
);

View File

@@ -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"],

View File

@@ -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>

View 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>
</>
);
}

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View 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>
);
};

View 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>
</>
);
});

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
</>
);
};

View 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>
</>
);
});

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
);
});

View File

@@ -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";

View 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>
</>
);
});

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./block-content";
export * from "./block-wrapper";
export * from "./button-label";

View 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>
);
});

View 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 &nbsp;</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>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./ai-message";
export * from "./messages";
export * from "./my-message";
export * from "./new-converstaion";

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
};

View 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;

View 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>
);
};

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from "./header";

View 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]", "");

View 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;
};

View File

@@ -0,0 +1 @@
export * from "./root";

View 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;

View 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>
);
};

View 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>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./root";
export * from "./floating-bot";

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
);
};

View 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>
);
};

View 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;

View 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 };

View File

@@ -0,0 +1 @@
export const DE_DUPE_SELECT_GROUP = "de-dupe-issues";

View File

@@ -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 = (

View 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;
};

View File

@@ -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 };
};

View File

@@ -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";

View 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;
});
}
}

View 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;
});
}
}

View File

@@ -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);
}
}

View 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;
};

View 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;
};
}

View File

@@ -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);
}
}

View File

@@ -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
View 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;
};

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -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;