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
16 changes: 16 additions & 0 deletions apps/desktop/src/components/editor-area/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { toast } from "@hypr/ui/components/ui/toast";
import { cn } from "@hypr/ui/lib/utils";
import { generateText, localProviderName, modelProvider, smoothStream, streamText, tool } from "@hypr/utils/ai";
import { useOngoingSession, useSession, useSessions } from "@hypr/utils/contexts";
import { globalEditorRef } from "../../shared/editor-ref";
import { enhanceFailedToast } from "../toast/shared";
import { AnnotationBox } from "./annotation-box";
import { FloatingButton } from "./floating-button";
Expand Down Expand Up @@ -128,6 +129,19 @@ export default function EditorArea({
}));

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

// Assign editor to global ref for access by other components (like chat tools)
useEffect(() => {
if (editorRef.current?.editor) {
globalEditorRef.current = editorRef.current.editor;
}
// Clear on unmount
return () => {
if (globalEditorRef.current === editorRef.current?.editor) {
globalEditorRef.current = null;
}
};
}, [editorRef.current?.editor]);
const editorKey = useMemo(
() => `session-${sessionId}-${showRaw ? "raw" : "enhanced"}`,
[sessionId, showRaw],
Expand Down Expand Up @@ -287,6 +301,8 @@ export default function EditorArea({
isEnhancedNote={isEnhancedNote}
onAnnotate={handleAnnotate}
isAnnotationBoxOpen={!!annotationBox}
sessionId={sessionId}
editorRef={editorRef}
/>
)}

Expand Down
110 changes: 100 additions & 10 deletions apps/desktop/src/components/editor-area/text-selection-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { MessageSquare, Sparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { useHypr } from "@/contexts";
import { useRightPanel } from "@/contexts/right-panel";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";

interface TextSelectionPopoverProps {
isEnhancedNote: boolean;
onAnnotate: (selectedText: string, selectedRect: DOMRect) => void;
onAskAI?: (selectedText: string) => void;
isAnnotationBoxOpen: boolean; // Add this prop
isAnnotationBoxOpen: boolean;
sessionId: string;
editorRef: React.RefObject<{ editor: any }>;
}

interface SelectionInfo {
Expand All @@ -18,11 +21,25 @@ interface SelectionInfo {
}

export function TextSelectionPopover(
{ isEnhancedNote, onAnnotate, onAskAI, isAnnotationBoxOpen }: TextSelectionPopoverProps,
{ isEnhancedNote, onAnnotate, onAskAI, isAnnotationBoxOpen, sessionId, editorRef }: TextSelectionPopoverProps,
) {
const [selection, setSelection] = useState<SelectionInfo | null>(null);
const delayTimeoutRef = useRef<NodeJS.Timeout>();
const { userId } = useHypr();
// Safe hook usage with fallback
const rightPanel = (() => {
try {
return useRightPanel();
} catch {
return {
sendSelectionToChat: () => {
console.warn("RightPanel not available - selection ignored");
},
};
}
})();

const { sendSelectionToChat } = rightPanel;

useEffect(() => {
if (!isEnhancedNote) {
Expand All @@ -49,9 +66,16 @@ export function TextSelectionPopover(
}

const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();

const editorElement = editorRef.current?.editor?.view?.dom;
if (!editorElement || !editorElement.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}

const rect = range.getBoundingClientRect();
const selectedText = sel.toString().trim();

if (selectedText.length > 0) {
delayTimeoutRef.current = setTimeout(() => {
setSelection({
Expand Down Expand Up @@ -104,10 +128,78 @@ export function TextSelectionPopover(
setSelection(null); // Hide the popover
};

// Helper to get TipTap/ProseMirror positions from DOM selection
const getTipTapPositions = () => {
const editor = editorRef.current?.editor;
if (!editor) {
console.warn("No TipTap editor available");
return null;
}

// Get current TipTap selection positions
const { from, to } = editor.state.selection;

// CLEAN HTML APPROACH: Extract selected content as HTML directly
let selectedHtml = "";
try {
// Get the selected DOM range
const selection = window.getSelection();
Copy link

Choose a reason for hiding this comment

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

Scope selection to the editor or derive HTML from the TipTap selection slice to ensure consistency.

Prompt for AI agents
Address the following comment on apps/desktop/src/components/editor-area/text-selection-popover.tsx at line 125:

<comment>Scope selection to the editor or derive HTML from the TipTap selection slice to ensure consistency.</comment>

<file context>
@@ -104,10 +107,91 @@ export function TextSelectionPopover(
     setSelection(null); // Hide the popover
   };
 
+  // Helper to get TipTap/ProseMirror positions from DOM selection
+  const getTipTapPositions = () =&gt; {
+    const editor = editorRef.current?.editor;
+    if (!editor) {
+      console.warn(&quot;No TipTap editor available&quot;);
+      return null;
</file context>

if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const fragment = range.cloneContents();

// Create a temporary div to get the HTML
const tempDiv = document.createElement("div");
tempDiv.appendChild(fragment);
selectedHtml = tempDiv.innerHTML;
}

// Fallback: if no DOM selection, use plain text
if (!selectedHtml) {
selectedHtml = editor.state.doc.textBetween(from, to);
}
} catch (error) {
console.warn("Could not extract HTML, falling back to plain text:", error);
selectedHtml = editor.state.doc.textBetween(from, to);
}

return {
from,
to,
text: selectedHtml, // Now contains HTML instead of plain text
};
};
Comment on lines +131 to +171
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Return both plain text and HTML; gate dev logs; clear naming.

Current helper names the HTML as “text” and logs to console unconditionally. Provide text and html separately, and avoid prod logging.

-  // Helper to get TipTap/ProseMirror positions from DOM selection
-  const getTipTapPositions = () => {
+  // Helper: derive ProseMirror range + text/html slice from current selection
+  const getTipTapSelection = () => {
     const editor = editorRef.current?.editor;
     if (!editor) {
-      console.warn("No TipTap editor available");
+      if (process.env.NODE_ENV !== "production") {
+        console.warn("No TipTap editor available");
+      }
       return null;
     }
 
-    // Get current TipTap selection positions
     const { from, to } = editor.state.selection;
 
-    // CLEAN HTML APPROACH: Extract selected content as HTML directly
-    let selectedHtml = "";
+    let html = "";
+    const text = editor.state.doc.textBetween(from, to);
     try {
-      // Get the selected DOM range
       const selection = window.getSelection();
       if (selection && selection.rangeCount > 0) {
         const range = selection.getRangeAt(0);
         const fragment = range.cloneContents();
 
-        // Create a temporary div to get the HTML
         const tempDiv = document.createElement("div");
         tempDiv.appendChild(fragment);
-        selectedHtml = tempDiv.innerHTML;
+        html = tempDiv.innerHTML;
       }
 
-      // Fallback: if no DOM selection, use plain text
-      if (!selectedHtml) {
-        selectedHtml = editor.state.doc.textBetween(from, to);
-      }
+      if (!html) html = text;
     } catch (error) {
-      console.warn("Could not extract HTML, falling back to plain text:", error);
-      selectedHtml = editor.state.doc.textBetween(from, to);
+      if (process.env.NODE_ENV !== "production") {
+        console.warn("HTML extract failed; falling back to text.", error);
+      }
+      html = text;
     }
 
     return {
       from,
       to,
-      text: selectedHtml, // Now contains HTML instead of plain text
+      text,
+      html,
     };
   };
📝 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
// Helper to get TipTap/ProseMirror positions from DOM selection
const getTipTapPositions = () => {
const editor = editorRef.current?.editor;
if (!editor) {
console.warn("No TipTap editor available");
return null;
}
// Get current TipTap selection positions
const { from, to } = editor.state.selection;
// CLEAN HTML APPROACH: Extract selected content as HTML directly
let selectedHtml = "";
try {
// Get the selected DOM range
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const fragment = range.cloneContents();
// Create a temporary div to get the HTML
const tempDiv = document.createElement("div");
tempDiv.appendChild(fragment);
selectedHtml = tempDiv.innerHTML;
}
// Fallback: if no DOM selection, use plain text
if (!selectedHtml) {
selectedHtml = editor.state.doc.textBetween(from, to);
}
} catch (error) {
console.warn("Could not extract HTML, falling back to plain text:", error);
selectedHtml = editor.state.doc.textBetween(from, to);
}
return {
from,
to,
text: selectedHtml, // Now contains HTML instead of plain text
};
};
// Helper: derive ProseMirror range + text/html slice from current selection
const getTipTapSelection = () => {
const editor = editorRef.current?.editor;
if (!editor) {
if (process.env.NODE_ENV !== "production") {
console.warn("No TipTap editor available");
}
return null;
}
const { from, to } = editor.state.selection;
let html = "";
const text = editor.state.doc.textBetween(from, to);
try {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const fragment = range.cloneContents();
const tempDiv = document.createElement("div");
tempDiv.appendChild(fragment);
html = tempDiv.innerHTML;
}
if (!html) html = text;
} catch (error) {
if (process.env.NODE_ENV !== "production") {
console.warn("HTML extract failed; falling back to text.", error);
}
html = text;
}
return {
from,
to,
text,
html,
};
};
🤖 Prompt for AI Agents
In apps/desktop/src/components/editor-area/text-selection-popover.tsx around
lines 131 to 171, the helper currently stores HTML in a variable named
"selectedHtml" but returns it as "text" and unconditionally uses console.warn;
update the function to produce and return two explicit fields (plainText and
html), rename variables for clarity (e.g., selectedHtml and selectedText),
ensure plainText is derived via editor.state.doc.textBetween(from, to) as
fallback or alongside HTML extraction, and gate any warning logs behind a
dev-only check (e.g., if (process.env.NODE_ENV !== 'production') or use the app
logger) so production builds do not emit console warnings. Ensure the returned
object is { from, to, plainText, html } and adjust call sites accordingly.


const handleAskAIClick = () => {
if (onAskAI) {
onAskAI(selection.text);
if (!selection) {
return;
}

// Get TipTap/ProseMirror positions (much more accurate)
const tipTapPositions = getTipTapPositions();
if (!tipTapPositions) {
console.error("Could not get TipTap positions");
return;
}

// Verify DOM selection matches TipTap selection
if (selection.text.trim() !== tipTapPositions.text.trim()) {
console.warn("DOM selection doesn't match TipTap selection:");
console.warn("DOM:", selection.text);
console.warn("TipTap:", tipTapPositions.text);
}

const selectionData = {
text: tipTapPositions.text, // Use TipTap's text (more reliable)
startOffset: tipTapPositions.from, // ProseMirror position
endOffset: tipTapPositions.to, // ProseMirror position
sessionId,
timestamp: Date.now(),
};

// Send selection to chat
sendSelectionToChat(selectionData);

setSelection(null);
};

Expand Down Expand Up @@ -147,24 +239,22 @@ export function TextSelectionPopover(
size="sm"
variant="ghost"
onClick={handleAnnotateClick}
className="flex items-center gap-1 text-xs h-5 px-1.5 hover:bg-neutral-100 font-normal"
className="flex items-center gap-1 text-xs h-6 px-2 hover:bg-neutral-100 font-normal"
>
<MessageSquare className="h-2.5 w-2.5" />
<span className="text-[11px]">Source</span>
</Button>

<div className="w-px h-3 bg-neutral-200 mx-0.5" />
<div className="w-px h-4 bg-neutral-200 mx-0.5" />

<Button
size="sm"
variant="ghost"
onClick={handleAskAIClick}
className="flex items-center gap-1 text-xs h-5 px-1.5 hover:bg-neutral-100 font-normal opacity-60 cursor-not-allowed"
disabled
className="flex items-center gap-1 text-xs h-6 px-2 hover:bg-neutral-100 font-normal"
>
<Sparkles className="h-2.5 w-2.5" />
<span className="text-[11px]">Ask AI</span>
<span className="text-[9px] text-neutral-400 ml-0.5">coming soon</span>
</Button>
</div>
);
Expand Down
110 changes: 94 additions & 16 deletions apps/desktop/src/components/right-panel/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ArrowUpIcon, BuildingIcon, FileTextIcon, Square, UserIcon } from "lucid
import { useCallback, useEffect, useRef } from "react";

import { useHypr, useRightPanel } from "@/contexts";
import type { SelectionData } from "@/contexts/right-panel";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { commands as dbCommands } from "@hypr/plugin-db";
import { Badge } from "@hypr/ui/components/ui/badge";
Expand All @@ -14,7 +15,11 @@ import Editor, { type TiptapEditor } from "@hypr/tiptap/editor";
interface ChatInputProps {
inputValue: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: (mentionedContent?: Array<{ id: string; type: string; label: string }>) => void;
onSubmit: (
mentionedContent?: Array<{ id: string; type: string; label: string }>,
selectionData?: SelectionData,
htmlContent?: string,
) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
autoFocus?: boolean;
entityId?: string;
Expand All @@ -39,7 +44,7 @@ export function ChatInput(
}: ChatInputProps,
) {
const { userId } = useHypr();
const { chatInputRef } = useRightPanel();
const { chatInputRef, pendingSelection, clearPendingSelection } = useRightPanel();

const lastBacklinkSearchTime = useRef<number>(0);

Expand Down Expand Up @@ -136,6 +141,7 @@ export function ChatInput(
}, [onChange, extractPlainText]);

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

const extractMentionedContent = useCallback(() => {
if (!editorRef.current?.editor) {
Expand Down Expand Up @@ -185,7 +191,20 @@ export function ChatInput(
const handleSubmit = useCallback(() => {
const mentionedContent = extractMentionedContent();

onSubmit(mentionedContent);
// Extract HTML content before clearing the editor
let htmlContent = "";
if (editorRef.current?.editor) {
htmlContent = editorRef.current.editor.getHTML();
}

// Pass the pending selection data and HTML content to the submit handler
onSubmit(mentionedContent, pendingSelection || undefined, htmlContent);

// Clear the selection after submission
clearPendingSelection();

// Reset processed selection so new selections can be processed
processedSelectionRef.current = null;

if (editorRef.current?.editor) {
editorRef.current.editor.commands.setContent("<p></p>");
Expand All @@ -197,23 +216,81 @@ export function ChatInput(

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

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

// Handle pending selection from text selection popover
useEffect(() => {
if (pendingSelection && editorRef.current?.editor) {
// Create a unique ID for this selection to avoid processing it multiple times
const selectionId = `${pendingSelection.startOffset}-${pendingSelection.endOffset}-${pendingSelection.timestamp}`;

// Only process if we haven't already processed this exact selection
if (processedSelectionRef.current !== selectionId) {
// Create compact reference with text preview instead of just positions
const noteName = noteData?.title || humanData?.full_name || organizationData?.name || "Note";

const selectedHtml = pendingSelection.text || "";

// Strip HTML tags to get plain text
const stripHtml = (html: string): string => {
const temp = document.createElement("div");
temp.innerHTML = html;
return temp.textContent || temp.innerText || "";
};

const selectedText = stripHtml(selectedHtml).trim();

const textPreview = selectedText.length > 0
? (selectedText.length > 6
? `'${selectedText.slice(0, 6)}...'` // Use single quotes instead!
: `'${selectedText}'`)
: "NO_TEXT";

const selectionRef = textPreview !== "NO_TEXT"
? `[${noteName} - ${textPreview}(${pendingSelection.startOffset}:${pendingSelection.endOffset})]`
: `[${noteName} - ${pendingSelection.startOffset}:${pendingSelection.endOffset}]`;

// Escape quotes for HTML attribute
const escapedSelectionRef = selectionRef.replace(/"/g, "&quot;");

const referenceText =
`<a class="mention selection-ref" data-mention="true" data-id="selection-${pendingSelection.startOffset}-${pendingSelection.endOffset}" data-type="selection" data-label="${escapedSelectionRef}" contenteditable="false">${selectionRef}</a> `;

editorRef.current.editor.commands.setContent(referenceText);
editorRef.current.editor.commands.focus("end");

// Clear the input value to match editor content
const syntheticEvent = {
target: { value: selectionRef },
currentTarget: { value: selectionRef },
} as React.ChangeEvent<HTMLTextAreaElement>;
onChange(syntheticEvent);

// Mark this selection as processed
processedSelectionRef.current = selectionId;
}
}
}, [pendingSelection, onChange, noteData?.title, humanData?.full_name, organizationData?.name]);

useEffect(() => {
const editor = editorRef.current?.editor;
if (editor) {
// override TipTap's Enter behavior completely
editor.setOptions({
editorProps: {
...editor.options.editorProps,
handleKeyDown: (view, event) => {
if (event.key === "Enter" && !event.shiftKey) {
const mentionDropdown = document.querySelector(".mention-container");
if (mentionDropdown) {
return false;
}

const isEmpty = view.state.doc.textContent.trim() === "";
if (isEmpty) {
return true;
Expand Down Expand Up @@ -296,23 +373,24 @@ export function ChatInput(
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 {
.chat-editor .tiptap-normal strong:not(.selection-ref),
.chat-editor .tiptap-normal em:not(.selection-ref),
.chat-editor .tiptap-normal u:not(.selection-ref),
.chat-editor .tiptap-normal h1:not(.selection-ref),
.chat-editor .tiptap-normal h2:not(.selection-ref),
.chat-editor .tiptap-normal h3:not(.selection-ref),
.chat-editor .tiptap-normal ul:not(.selection-ref),
.chat-editor .tiptap-normal ol:not(.selection-ref),
.chat-editor .tiptap-normal blockquote:not(.selection-ref),
.chat-editor .tiptap-normal span:not(.selection-ref) {
all: unset !important;
display: inline !important;
}
.chat-editor .tiptap-normal p {
margin: 0 !important;
display: block !important;
}
.chat-editor .mention {
.chat-editor .mention:not(.selection-ref) {
color: #3b82f6 !important;
font-weight: 500 !important;
text-decoration: none !important;
Expand All @@ -323,7 +401,7 @@ export function ChatInput(
cursor: default !important;
pointer-events: none !important;
}
.chat-editor .mention:hover {
.chat-editor .mention:not(.selection-ref):hover {
background-color: rgba(59, 130, 246, 0.08) !important;
text-decoration: none !important;
}
Expand Down
Loading
Loading