Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import { useRightPanel } from "@/contexts";
import { MessageCircleMore } from "lucide-react";
import { EventChip } from "./event-chip";
import { ParticipantsChip } from "./participants-chip";
import { PastNotesChip } from "./past-notes-chip";
import { TagChip } from "./tag-chip";

function StartChatButton() {
const { togglePanel } = useRightPanel();

const handleChatClick = () => {
togglePanel("chat");
};

return (
<button
onClick={handleChatClick}
className="flex flex-row items-center gap-1 rounded-md px-2 py-1.5 hover:bg-neutral-100 flex-shrink-0 text-xs transition-colors"
>
<MessageCircleMore size={14} className="flex-shrink-0" />
<span className="truncate">Chat</span>
</button>
);
}

export default function NoteHeaderChips({ sessionId, hashtags = [] }: {
sessionId: string;
hashtags?: string[];
Expand All @@ -12,6 +32,7 @@ export default function NoteHeaderChips({ sessionId, hashtags = [] }: {
<EventChip sessionId={sessionId} />
<ParticipantsChip sessionId={sessionId} />
<TagChip sessionId={sessionId} hashtags={hashtags} />
<StartChatButton />
<PastNotesChip sessionId={sessionId} />
</div>
);
Expand Down
279 changes: 249 additions & 30 deletions apps/desktop/src/components/right-panel/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowUpIcon, BuildingIcon, FileTextIcon, UserIcon } from "lucide-react";
import { useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";

import { useRightPanel } from "@/contexts";
import { useHypr, useRightPanel } from "@/contexts";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { commands as dbCommands } from "@hypr/plugin-db";
import { Badge } from "@hypr/ui/components/ui/badge";
import { Button } from "@hypr/ui/components/ui/button";
import { BadgeType } from "../../types/chat-types";

import Editor, { type TiptapEditor } from "@hypr/tiptap/editor";

interface ChatInputProps {
inputValue: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: () => void;
onSubmit: (mentionedContent?: Array<{ id: string; type: string; label: string }>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
autoFocus?: boolean;
entityId?: string;
Expand All @@ -33,8 +36,11 @@ export function ChatInput(
isGenerating = false,
}: ChatInputProps,
) {
const { userId } = useHypr();
const { chatInputRef } = useRightPanel();

const lastBacklinkSearchTime = useRef<number>(0);

const { data: noteData } = useQuery({
queryKey: ["session", entityId],
queryFn: async () => entityId ? dbCommands.getSession({ id: entityId }) : null,
Expand Down Expand Up @@ -70,28 +76,171 @@ export function ChatInput(
}
};

useEffect(() => {
const textarea = chatInputRef.current;
if (!textarea) {
return;
const handleMentionSearch = useCallback(async (query: string) => {
const now = Date.now();
const timeSinceLastEvent = now - lastBacklinkSearchTime.current;

if (timeSinceLastEvent >= 5000) {
analyticsCommands.event({
event: "searched_backlink",
distinct_id: userId,
});
lastBacklinkSearchTime.current = now;
}

textarea.style.height = "auto";
const sessions = await dbCommands.listSessions({
type: "search",
query,
user_id: userId,
limit: 3,
});

const baseHeight = 40;
const newHeight = Math.max(textarea.scrollHeight, baseHeight);
textarea.style.height = `${newHeight}px`;
}, [inputValue, chatInputRef]);
const noteResults = sessions.map((s) => ({
id: s.id,
type: "note" as const,
label: s.title || "Untitled Note",
}));

useEffect(() => {
const textarea = chatInputRef.current;
if (textarea) {
textarea.style.height = "40px";
if (autoFocus) {
textarea.focus();
const humans = await dbCommands.listHumans({
search: [3, query],
});

const peopleResults = humans
.filter(h => h.full_name && h.full_name.toLowerCase().includes(query.toLowerCase()))
.map((h) => ({
id: h.id,
type: "human" as const,
label: h.full_name || "Unknown Person",
}));

return [...noteResults, ...peopleResults].slice(0, 5);
}, [userId]);

const extractPlainText = useCallback((html: string) => {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || div.innerText || "";
}, []);

const handleContentChange = useCallback((html: string) => {
const plainText = extractPlainText(html);

const syntheticEvent = {
target: { value: plainText },
currentTarget: { value: plainText },
} as React.ChangeEvent<HTMLTextAreaElement>;

onChange(syntheticEvent);
}, [onChange, extractPlainText]);

const editorRef = useRef<{ editor: TiptapEditor | null }>(null);

const extractMentionedContent = useCallback(() => {
if (!editorRef.current?.editor) {
return [];
}

const doc = editorRef.current.editor.getJSON();
const mentions: Array<{ id: string; type: string; label: string }> = [];

const traverseNode = (node: any) => {
if (node.type === "mention" || node.type === "mention-@") {
if (node.attrs) {
mentions.push({
id: node.attrs.id || node.attrs["data-id"],
type: node.attrs.type || node.attrs["data-type"] || "note",
label: node.attrs.label || node.attrs["data-label"] || "Unknown",
});
}
}

if (node.marks && Array.isArray(node.marks)) {
node.marks.forEach((mark: any) => {
if (mark.type === "mention" || mark.type === "mention-@") {
if (mark.attrs) {
mentions.push({
id: mark.attrs.id || mark.attrs["data-id"],
type: mark.attrs.type || mark.attrs["data-type"] || "note",
label: mark.attrs.label || mark.attrs["data-label"] || "Unknown",
});
}
}
});
}

if (node.content && Array.isArray(node.content)) {
node.content.forEach(traverseNode);
}
};

if (doc.content) {
doc.content.forEach(traverseNode);
}
}, [autoFocus, chatInputRef]);

return mentions;
}, []);

const handleSubmit = useCallback(() => {
const mentionedContent = extractMentionedContent();

onSubmit(mentionedContent);

if (editorRef.current?.editor) {
editorRef.current.editor.commands.setContent("<p></p>");

const syntheticEvent = {
target: { value: "" },
currentTarget: { value: "" },
} as React.ChangeEvent<HTMLTextAreaElement>;

onChange(syntheticEvent);
}
}, [onSubmit, onChange, extractMentionedContent]);

useEffect(() => {
if (chatInputRef && typeof chatInputRef === "object" && editorRef.current?.editor) {
(chatInputRef as any).current = editorRef.current.editor.view.dom;
}
}, [chatInputRef]);

useEffect(() => {
const editor = editorRef.current?.editor;
if (editor) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
if (["b", "i", "u", "k"].includes(event.key.toLowerCase())) {
event.preventDefault();
return;
}
}

if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();

if (inputValue.trim()) {
handleSubmit();
}
}
};

const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target && (target.classList.contains("mention") || target.closest(".mention"))) {
event.preventDefault();
event.stopPropagation();
return false;
}
};

editor.view.dom.addEventListener("keydown", handleKeyDown);
editor.view.dom.addEventListener("click", handleClick);

return () => {
editor.view.dom.removeEventListener("keydown", handleKeyDown);
editor.view.dom.removeEventListener("click", handleClick);
};
}
}, [editorRef.current?.editor, onKeyDown, handleSubmit, inputValue]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Unstable dependency in useEffect

Including editorRef.current?.editor in the dependency array can cause the effect to run on every render since the editor instance might change.

-  }, [editorRef.current?.editor, onKeyDown, handleSubmit, inputValue]);
+  }, [onKeyDown, handleSubmit, inputValue]);

Instead, check for editor existence inside the effect and use a ref to track if listeners are already attached.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}, [editorRef.current?.editor, onKeyDown, handleSubmit, inputValue]);
}, [onKeyDown, handleSubmit, inputValue]);
🤖 Prompt for AI Agents
In apps/desktop/src/components/right-panel/components/chat/chat-input.tsx at
line 243, remove editorRef.current?.editor from the useEffect dependency array
to prevent the effect from running on every render due to unstable reference
changes. Instead, inside the effect, check if the editor instance exists and use
a separate ref to track whether event listeners have already been attached,
ensuring listeners are added only once.


const getBadgeIcon = () => {
switch (entityType) {
Expand All @@ -109,16 +258,86 @@ export function ChatInput(

return (
<div className="border border-b-0 border-input mx-4 rounded-t-lg overflow-clip flex flex-col bg-white">
<textarea
ref={chatInputRef}
value={inputValue}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder="Type a message..."
className="w-full resize-none overflow-hidden px-3 py-2 pr-10 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 min-h-[40px] max-h-[120px]"
rows={1}
disabled={isGenerating}
/>
{/* Custom styles to disable rich text features */}
<style>
{`
.chat-editor .tiptap-normal {
padding: 12px 40px 12px 12px !important;
min-height: 40px !important;
max-height: 120px !important;
font-size: 14px !important;
line-height: 1.5 !important;
}
.chat-editor .tiptap-normal strong,
.chat-editor .tiptap-normal em,
.chat-editor .tiptap-normal u,
.chat-editor .tiptap-normal h1,
.chat-editor .tiptap-normal h2,
.chat-editor .tiptap-normal h3,
.chat-editor .tiptap-normal ul,
.chat-editor .tiptap-normal ol,
.chat-editor .tiptap-normal blockquote {
all: unset !important;
display: inline !important;
}
.chat-editor .tiptap-normal p {
margin: 0 !important;
display: inline !important;
}
.chat-editor .mention {
color: #3b82f6 !important;
font-weight: 500 !important;
text-decoration: none !important;
border-radius: 0.25rem !important;
background-color: rgba(59, 130, 246, 0.08) !important;
padding: 0.1rem 0.25rem !important;
font-size: 0.9rem !important;
cursor: default !important;
pointer-events: none !important;
}
.chat-editor .mention:hover {
background-color: rgba(59, 130, 246, 0.08) !important;
text-decoration: none !important;
}
.chat-editor.has-content .tiptap-normal .is-empty::before {
display: none !important;
}
.chat-editor:not(.has-content) .tiptap-normal .is-empty::before {
content: "Ask anything about this note..." !important;
float: left;
color: #9ca3af;
pointer-events: none;
height: 0;
}
.chat-editor .placeholder-overlay {
position: absolute;
top: 12px;
left: 12px;
right: 40px;
color: #9ca3af;
pointer-events: none;
font-size: 14px;
line-height: 1.5;
}
`}
</style>

<div className={`relative chat-editor ${inputValue.trim() ? "has-content" : ""}`}>
<Editor
ref={editorRef}
handleChange={handleContentChange}
initialContent={inputValue || ""}
editable={!isGenerating}
mentionConfig={{
trigger: "@",
handleSearch: handleMentionSearch,
}}
/>
{isGenerating && !inputValue.trim() && (
<div className="placeholder-overlay">Ask anything about this note...</div>
)}
</div>

<div className="flex items-center justify-between pb-2 px-3">
{entityId
? (
Expand All @@ -134,7 +353,7 @@ export function ChatInput(

<Button
size="icon"
onClick={onSubmit}
onClick={handleSubmit}
disabled={!inputValue.trim() || isGenerating}
>
<ArrowUpIcon className="h-4 w-4" />
Expand Down
Loading
Loading