diff --git a/apps/desktop/src/components/editor-area/floating-search-box.tsx b/apps/desktop/src/components/editor-area/floating-search-box.tsx new file mode 100644 index 0000000000..95606ec743 --- /dev/null +++ b/apps/desktop/src/components/editor-area/floating-search-box.tsx @@ -0,0 +1,304 @@ +import { type TiptapEditor } from "@hypr/tiptap/editor"; +import { type TranscriptEditorRef } from "@hypr/tiptap/transcript"; +import { Button } from "@hypr/ui/components/ui/button"; +import { Input } from "@hypr/ui/components/ui/input"; +import useDebouncedCallback from "beautiful-react-hooks/useDebouncedCallback"; +import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface FloatingSearchBoxProps { + editorRef: React.RefObject | React.RefObject<{ editor: TiptapEditor | null }>; + onClose: () => void; + isVisible: boolean; +} + +export function FloatingSearchBox({ editorRef, onClose, isVisible }: FloatingSearchBoxProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [replaceTerm, setReplaceTerm] = useState(""); + const [resultCount, setResultCount] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); + + // Get the editor - NO useCallback, we want fresh ref every time + const getEditor = () => { + const ref = editorRef.current; + if (!ref) { + return null; + } + + // For both normal editor and transcript editor, just access the editor property + if ("editor" in ref && ref.editor) { + return ref.editor; + } + + return null; + }; + + // Add ref for the search box container + const searchBoxRef = useRef(null); + + // Debounced search term update - NO getEditor in deps + const debouncedSetSearchTerm = useDebouncedCallback( + (value: string) => { + const editor = getEditor(); + if (editor && editor.commands) { + try { + editor.commands.setSearchTerm(value); + editor.commands.resetIndex(); + setTimeout(() => { + const storage = editor.storage?.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + }, 100); + } catch (e) { + // Editor might not be ready yet, ignore + console.warn("Editor not ready for search:", e); + } + } + }, + [], // Empty deps to prevent infinite re-creation + 300, + ); + + useEffect(() => { + debouncedSetSearchTerm(searchTerm); + }, [searchTerm, debouncedSetSearchTerm]); + + useEffect(() => { + const editor = getEditor(); + if (editor && editor.commands) { + try { + editor.commands.setReplaceTerm(replaceTerm); + } catch (e) { + // Editor might not be ready yet, ignore + } + } + }, [replaceTerm]); // Removed getEditor from deps + + // Click outside handler + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchBoxRef.current && !searchBoxRef.current.contains(event.target as Node)) { + handleClose(); + } + }; + + if (isVisible) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [isVisible]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + } + }; + + if (isVisible) { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + } + }, [isVisible]); + + const scrollCurrentResultIntoView = useCallback(() => { + const editor = getEditor(); + if (!editor) { + return; + } + + try { + const editorElement = editor.view.dom; + const current = editorElement.querySelector(".search-result-current") as HTMLElement | null; + if (current) { + current.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } + } catch (e) { + // Editor view not ready yet, ignore + } + }, []); + + const handleNext = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.nextSearchResult(); + setTimeout(() => { + const storage = editor.storage.searchAndReplace; + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + scrollCurrentResultIntoView(); + }, 100); + } + }, [scrollCurrentResultIntoView]); + + const handlePrevious = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.previousSearchResult(); + setTimeout(() => { + const storage = editor.storage.searchAndReplace; + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + scrollCurrentResultIntoView(); + }, 100); + } + }, [scrollCurrentResultIntoView]); + + const handleReplace = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.replace(); + setTimeout(() => { + const storage = editor.storage.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + }, 100); + } + }, []); + + const handleReplaceAll = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.replaceAll(); + setTimeout(() => { + const storage = editor.storage.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex(0); + }, 100); + } + }, []); + + const handleClose = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.setSearchTerm(""); + } + setSearchTerm(""); + setReplaceTerm(""); + setResultCount(0); + setCurrentIndex(0); + onClose(); + }, [onClose]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + handlePrevious(); + } else { + handleNext(); + } + } else if (e.key === "F3") { + e.preventDefault(); + if (e.shiftKey) { + handlePrevious(); + } else { + handleNext(); + } + } + }; + + if (!isVisible) { + return null; + } + + return ( +
+
+
+ {/* Search Input */} +
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search..." + autoFocus + /> +
+ + {/* Results Counter */} + {searchTerm && ( + + {resultCount > 0 ? `${currentIndex}/${resultCount}` : "0/0"} + + )} + + {/* Navigation Buttons */} + + + + {/* Close Button */} + +
+ + {/* Replace Row */} +
+ {/* Replace Input */} +
+ setReplaceTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Replace..." + /> +
+ + {/* Replace Buttons */} + + +
+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/index.tsx b/apps/desktop/src/components/editor-area/index.tsx index 068ae49f4d..03ca4d4041 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -1,11 +1,11 @@ import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import usePreviousValue from "beautiful-react-hooks/usePreviousValue"; import { diffWords } from "diff"; -import { motion } from "motion/react"; -import { AnimatePresence } from "motion/react"; + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHypr } from "@/contexts"; +import { useRightPanel } from "@/contexts/right-panel"; import { extractTextFromHtml } from "@/utils/parse"; import { autoTagGeneration } from "@/utils/tag-generation"; import { TemplateService } from "@/utils/template-service"; @@ -19,6 +19,7 @@ import { commands as templateCommands, type Grammar } from "@hypr/plugin-templat import Editor, { type TiptapEditor } from "@hypr/tiptap/editor"; import Renderer from "@hypr/tiptap/renderer"; import { extractHashtags } from "@hypr/tiptap/shared"; +import { type TranscriptEditorRef } from "@hypr/tiptap/transcript"; import { toast } from "@hypr/ui/components/ui/toast"; import { cn } from "@hypr/ui/lib/utils"; import { localProviderName, modelProvider, smoothStream, streamText } from "@hypr/utils/ai"; @@ -26,9 +27,11 @@ 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"; -import { NoteHeader } from "./note-header"; +import { LocalSearchBar } from "./local-search-bar"; +import { NoteHeader, TabHeader, type TabHeaderRef } from "./note-header"; +import { EnhancedNoteSubHeader } from "./note-header/sub-headers/enhanced-note-sub-header"; import { TextSelectionPopover } from "./text-selection-popover"; +import { TranscriptViewer } from "./transcript-viewer"; import { prepareContextText } from "./utils/summary-prepare"; const TIPS_MODAL_SHOWN_KEY = "hypr-tips-modal-shown-v1"; @@ -125,7 +128,9 @@ export default function EditorArea({ sessionId: string; }) { const showRaw = useSession(sessionId, (s) => s.showRaw); + const activeTab = useSession(sessionId, (s) => s.activeTab); const { userId, onboardingSessionId, thankYouSessionId } = useHypr(); + const { isExpanded: isRightPanelExpanded, togglePanel: toggleRightPanel } = useRightPanel(); const [rawContent, setRawContent] = useSession(sessionId, (s) => [ s.session?.raw_memo_html ?? "", @@ -138,11 +143,16 @@ export default function EditorArea({ s.updateEnhancedNote, ]); - const sessionStore = useSession(sessionId, (s) => ({ - session: s.session, - })); - const editorRef = useRef<{ editor: TiptapEditor | null }>(null); + const transcriptRef = useRef(null); + const tabHeaderRef = useRef(null); + const [transcriptEditorRef, setTranscriptEditorRef] = useState(null); + const [isFloatingSearchVisible, setIsFloatingSearchVisible] = useState(false); + + // Update transcriptRef to point to the TranscriptEditorRef + useEffect(() => { + transcriptRef.current = transcriptEditorRef; + }, [transcriptEditorRef]); // Assign editor to global ref for access by other components (like chat tools) useEffect(() => { @@ -156,17 +166,25 @@ export default function EditorArea({ } }; }, [editorRef.current?.editor]); + + // Floating search keyboard listener for all tabs + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + e.preventDefault(); + setIsFloatingSearchVisible(true); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + const editorKey = useMemo( () => `session-${sessionId}-${showRaw ? "raw" : "enhanced"}`, [sessionId, showRaw], ); - const templatesQuery = useQuery({ - queryKey: ["templates"], - queryFn: () => TemplateService.getAllTemplates(), - refetchOnWindowFocus: true, - }); - const preMeetingNote = useSession(sessionId, (s) => s.session.pre_meeting_memo_html) ?? ""; const hasTranscriptWords = useSession(sessionId, (s) => s.session.words.length > (import.meta.env.DEV ? 5 : 100)); @@ -178,7 +196,7 @@ export default function EditorArea({ const sessionsStore = useSessions((s) => s.sessions); const queryClient = useQueryClient(); - const { enhance, progress, isCancelled } = useEnhanceMutation({ + const { enhance, progress } = useEnhanceMutation({ sessionId, preMeetingNote, rawContent, @@ -195,6 +213,11 @@ export default function EditorArea({ if (shouldShow) { localStorage.setItem(TIPS_MODAL_SHOWN_KEY, "true"); showTipsModal(userId); + } else { + // comment out to turn off auto-open chat panel + if (!isRightPanelExpanded) { + toggleRightPanel("chat"); + } } } catch (error) { console.error("Failed to show tips modal:", error); @@ -223,18 +246,9 @@ export default function EditorArea({ const noteContent = useMemo( () => (showRaw ? rawContent : enhancedContent), - [showRaw, enhancedContent, rawContent], + [showRaw, showRaw ? rawContent : enhancedContent], ); - const handleEnhanceWithTemplate = useCallback((templateId: string) => { - const targetTemplateId = templateId === "auto" ? null : templateId; - enhance.mutate({ templateId: targetTemplateId }); - }, [enhance]); - - const handleClickEnhance = useCallback(() => { - enhance.mutate({}); - }, [enhance]); - const safelyFocusEditor = useCallback(() => { if (editorRef.current?.editor && editorRef.current.editor.isEditable) { requestAnimationFrame(() => { @@ -285,6 +299,33 @@ export default function EditorArea({ return (
+ {/* Local search bar (slide-down) */} + setIsFloatingSearchVisible(false)} + isVisible={isFloatingSearchVisible} + /> + {/* Date placeholder - closer when search bar is visible */} +
+ { + /* + +
+ + Today, December 19, 2024 + +
+
+ */ + } +
+ -
{ - const target = e.target as HTMLElement; - if (!target.closest("a[href]")) { - e.stopPropagation(); - safelyFocusEditor(); - } - }} - > - {editable - ? ( - + + {/* Editor region wrapper: keeps overlay fixed while inner content scrolls */} +
+ {activeTab === "enhanced" && ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + - ) - : } +
+ )} +
{ + if (activeTab === "transcript") { + return; // Don't focus editor on transcript tab + } + + const target = e.target as HTMLElement; + if (!target.closest("a[href]")) { + e.stopPropagation(); + safelyFocusEditor(); + } + }} + > + {activeTab === "transcript" + ? + : editable + ? ( + + ) + : } +
+ { + /** + * FloatingSearchBox temporarily disabled in favor of LocalSearchBar + * setIsFloatingSearchVisible(false)} + * isVisible={isFloatingSearchVisible} + * /> + */ + } + {/* Add the text selection popover - but not for onboarding sessions */} {sessionId !== onboardingSessionId && ( )} - + { + /* +
-
+
*/ + }
); } diff --git a/apps/desktop/src/components/editor-area/local-search-bar.tsx b/apps/desktop/src/components/editor-area/local-search-bar.tsx new file mode 100644 index 0000000000..73c78f06a8 --- /dev/null +++ b/apps/desktop/src/components/editor-area/local-search-bar.tsx @@ -0,0 +1,290 @@ +import { type TiptapEditor } from "@hypr/tiptap/editor"; +import { type TranscriptEditorRef } from "@hypr/tiptap/transcript"; +import { Button } from "@hypr/ui/components/ui/button"; +import { Input } from "@hypr/ui/components/ui/input"; +import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface LocalSearchBarProps { + editorRef: React.RefObject | React.RefObject<{ editor: TiptapEditor | null }>; + onClose: () => void; + isVisible: boolean; +} + +// A full-width, slide-down search/replace bar that reuses FloatingSearchBox logic +export function LocalSearchBar({ editorRef, onClose, isVisible }: LocalSearchBarProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [replaceTerm, setReplaceTerm] = useState(""); + const [resultCount, setResultCount] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); + + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + const getEditor = () => { + const ref = editorRef.current as any; + if (!ref) { + return null; + } + if ("editor" in ref && ref.editor) { + return ref.editor as TiptapEditor; + } + return null; + }; + + // Focus search input on open + useEffect(() => { + if (isVisible) { + // Delay slightly to ensure mount before focus + setTimeout(() => searchInputRef.current?.focus(), 0); + } + }, [isVisible]); + + // Apply search term and compute counts + const applySearch = useCallback((value: string) => { + const editor = getEditor(); + if (editor && editor.commands) { + try { + editor.commands.setSearchTerm(value); + editor.commands.resetIndex(); + setTimeout(() => { + const storage = (editor as any).storage?.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + }, 100); + } catch { + // ignore if editor not ready + } + } + }, []); + + useEffect(() => { + if (isVisible) { + applySearch(searchTerm); + } + }, [searchTerm, isVisible, applySearch]); + + // Replace term binding + useEffect(() => { + if (!isVisible) { + return; + } + const editor = getEditor(); + if (editor && editor.commands) { + try { + editor.commands.setReplaceTerm(replaceTerm); + } catch { + // ignore + } + } + }, [replaceTerm, isVisible]); + + // Close on outside click + /* + useEffect(() => { + if (!isVisible) return; + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + handleClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isVisible]); + */ + + // Close on Escape + useEffect(() => { + if (!isVisible) { + return; + } + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isVisible]); + + const scrollCurrentResultIntoView = useCallback(() => { + const editor = getEditor(); + if (!editor) { + return; + } + try { + const editorElement = (editor as any).view?.dom as HTMLElement | undefined; + const current = editorElement?.querySelector(".search-result-current") as HTMLElement | null; + current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); + } catch { + // ignore + } + }, []); + + const handleNext = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.nextSearchResult(); + setTimeout(() => { + const storage = (editor as any).storage?.searchAndReplace; + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + scrollCurrentResultIntoView(); + }, 100); + } + }, [scrollCurrentResultIntoView]); + + const handlePrevious = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.previousSearchResult(); + setTimeout(() => { + const storage = (editor as any).storage?.searchAndReplace; + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + scrollCurrentResultIntoView(); + }, 100); + } + }, [scrollCurrentResultIntoView]); + + const handleReplace = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.replace(); + setTimeout(() => { + const storage = (editor as any).storage?.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex((storage?.resultIndex ?? 0) + 1); + }, 100); + } + }, []); + + const handleReplaceAll = useCallback(() => { + const editor = getEditor(); + if (editor) { + editor.commands.replaceAll(); + setTimeout(() => { + const storage = (editor as any).storage?.searchAndReplace; + const results = storage?.results || []; + setResultCount(results.length); + setCurrentIndex(0); + }, 100); + } + }, []); + + const handleClose = useCallback(() => { + const editor = getEditor(); + if (editor) { + try { + editor.commands.setSearchTerm(""); + } catch {} + } + setSearchTerm(""); + setReplaceTerm(""); + setResultCount(0); + setCurrentIndex(0); + onClose(); + }, [onClose]); + + const handleEnterNav = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + handlePrevious(); + } else { + handleNext(); + } + } else if (e.key === "F3") { + e.preventDefault(); + if (e.shiftKey) { + handlePrevious(); + } else { + handleNext(); + } + } + }; + + return ( +
+
+
+
+ {/* Search */} +
+ setSearchTerm(e.target.value)} + onKeyDown={handleEnterNav} + placeholder="Search..." + /> +
+ + {/* Replace */} +
+ setReplaceTerm(e.target.value)} + onKeyDown={handleEnterNav} + placeholder="Replace..." + /> +
+ + {/* Count */} + {searchTerm && ( + + {resultCount > 0 ? `${currentIndex}/${resultCount}` : "0/0"} + + )} + + {/* Prev/Next */} + + + + {/* Replace actions */} + + + + {/* Close */} + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/metadata-modal.tsx b/apps/desktop/src/components/editor-area/metadata-modal.tsx new file mode 100644 index 0000000000..c1cfcd686e --- /dev/null +++ b/apps/desktop/src/components/editor-area/metadata-modal.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { EventChip } from "./note-header/chips/event-chip"; +import { ParticipantsChip } from "./note-header/chips/participants-chip"; +import { TagChip } from "./note-header/chips/tag-chip"; + +interface MetadataModalProps { + sessionId: string; + children: React.ReactNode; + hashtags?: string[]; +} + +export function MetadataModal({ sessionId, children, hashtags = [] }: MetadataModalProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Keep original element visible with dynamic text color */} +
+ {children} +
+ + {/* Dark popover below the date */} + {isHovered && ( +
+ {/* Add invisible padding around the entire modal for easier hovering */} +
+ {/* Arrow pointing up - seamlessly connected */} +
+
+
+ + {/* Light popover content */} +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/editor-area/note-header/chips/event-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/event-chip.tsx index 685e090762..0622e5c36c 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/event-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/event-chip.tsx @@ -109,8 +109,8 @@ export function EventChip({ sessionId, isVeryNarrow = false, isNarrow = false }: className={`flex flex-row items-center gap-2 rounded-md ${isVeryNarrow ? "px-1.5 py-1" : "px-2 py-1.5"}`} style={{ outline: "none" }} > - - {!isVeryNarrow &&

{formatRelativeWithDay(date)}

} + + {!isVeryNarrow &&

{formatRelativeWithDay(date)}

}
); } @@ -145,9 +145,11 @@ export function EventChip({ sessionId, isVeryNarrow = false, isNarrow = false }: )} > {event.data.meetingLink - ? - : } - {!isVeryNarrow &&

{formatRelativeWithDay(date)}

} + ? + : } + {!isVeryNarrow && ( +

{formatRelativeWithDay(date)}

+ )} @@ -248,8 +250,10 @@ export function EventChip({ sessionId, isVeryNarrow = false, isNarrow = false }: isVeryNarrow ? "px-1.5 py-1" : "px-2 py-1.5" }`} > - - {!isVeryNarrow &&

{formatRelativeWithDay(sessionCreatedAt)}

} + + {!isVeryNarrow && ( +

{formatRelativeWithDay(sessionCreatedAt)}

+ )} diff --git a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx index f8e6a66a1f..84324155d3 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx @@ -4,7 +4,6 @@ import { EventChip } from "./event-chip"; import { ParticipantsChip } from "./participants-chip"; import { PastNotesChip } from "./past-notes-chip"; -import { ShareChip } from "./share-chip"; import { TagChip } from "./tag-chip"; // Temporarily commented out StartChatButton @@ -48,7 +47,7 @@ export default function NoteHeaderChips({ - + {/**/} {/* Temporarily commented out chat button */} {/* */} diff --git a/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx index 1ecc4a6f66..c3c3f555b7 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/participants-chip.tsx @@ -99,8 +99,8 @@ export function ParticipantsChip({ isVeryNarrow ? "px-1.5 py-1" : "px-2 py-1.5" }`} > - - {buttonText} + + {buttonText} {count > 1 && !isVeryNarrow && !isNarrow && + {count - 1}} @@ -112,7 +112,7 @@ export function ParticipantsChip({ ); } -function ParticipantsChipInner( +export function ParticipantsChipInner( { sessionId, handleClickHuman }: { sessionId: string; handleClickHuman: (human: Human) => void }, ) { const participants = useParticipantsWithOrg(sessionId); diff --git a/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx index f54a6933bf..adfca92410 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx @@ -57,12 +57,12 @@ export function TagChip({ sessionId, hashtags = [], isVeryNarrow = false, isNarr isVeryNarrow ? "px-1.5 py-1" : "px-2 py-1.5" } ${hasPendingActions ? "bg-gradient-to-r from-blue-50 to-purple-50 animate-pulse shadow-sm" : ""}`} > - + {hasPendingActions && (
)} {!isVeryNarrow && ( - + {getTagText()} )} diff --git a/apps/desktop/src/components/editor-area/note-header/index.tsx b/apps/desktop/src/components/editor-area/note-header/index.tsx index afd4f6f508..b4de8a8943 100644 --- a/apps/desktop/src/components/editor-area/note-header/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/index.tsx @@ -7,6 +7,8 @@ import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { useSession } from "@hypr/utils/contexts"; import Chips from "./chips"; import ListenButton from "./listen-button"; +import { TabHeader } from "./tab-header"; +import { TabSubHeader } from "./tab-sub-header"; import TitleInput from "./title-input"; import TitleShimmer from "./title-shimmer"; @@ -22,8 +24,14 @@ export function NoteHeader( ) { const updateTitle = useSession(sessionId, (s) => s.updateTitle); const sessionTitle = useSession(sessionId, (s) => s.session.title); + const session = useSession(sessionId, (s) => s.session); const isTitleGenerating = useTitleGenerationPendingState(sessionId); + const isNewNote = !sessionTitle?.trim() + && (!session.raw_memo_html || session.raw_memo_html === "

") + && (!session.enhanced_memo_html || session.enhanced_memo_html === "

") + && session.words.length === 0; + const containerRef = useRef(null); const headerWidth = useContainerWidth(containerRef); @@ -48,7 +56,7 @@ export function NoteHeader( return (
@@ -60,8 +68,10 @@ export function NoteHeader( onChange={handleTitleChange} onNavigateToEditor={onNavigateToEditor} isGenerating={isTitleGenerating} + autoFocus={isNewNote && editable} /> + ); } + +// Export the TabHeader and TabSubHeader components for use outside this directory +export { TabHeader, TabSubHeader }; +export type { TabHeaderRef } from "./tab-header"; diff --git a/apps/desktop/src/components/editor-area/note-header/sub-headers/enhanced-note-sub-header.tsx b/apps/desktop/src/components/editor-area/note-header/sub-headers/enhanced-note-sub-header.tsx new file mode 100644 index 0000000000..b76a7f5bfe --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/sub-headers/enhanced-note-sub-header.tsx @@ -0,0 +1,299 @@ +import { Button } from "@hypr/ui/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; +import { Spinner } from "@hypr/ui/components/ui/spinner"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDownIcon, PlusIcon, RefreshCwIcon, XIcon } from "lucide-react"; +import { useState } from "react"; + +import { TemplateService } from "@/utils/template-service"; +import { commands as connectorCommands } from "@hypr/plugin-connector"; +import { commands as windowsCommands } from "@hypr/plugin-windows"; +import { fetch } from "@hypr/utils"; +import { useOngoingSession } from "@hypr/utils/contexts"; +// import { useShareLogic } from "../share-button-header"; + +interface EnhancedNoteSubHeaderProps { + sessionId: string; + onEnhance?: (params: { triggerType: "manual" | "template"; templateId?: string | null }) => void; + isEnhancing?: boolean; + progress?: number; + showProgress?: boolean; +} + +export function EnhancedNoteSubHeader({ + sessionId, + onEnhance, + isEnhancing, + progress = 0, + showProgress = false, +}: EnhancedNoteSubHeaderProps) { + const [isTemplateDropdownOpen, setIsTemplateDropdownOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + // Cancel enhancement functionality + const cancelEnhance = useOngoingSession((s) => s.cancelEnhance); + + // Share functionality (currently commented out) + // const { hasEnhancedNote } = useShareLogic(); + + const templatesQuery = useQuery({ + queryKey: ["templates"], + queryFn: () => + TemplateService.getAllTemplates().then((templates) => + templates.map((template) => { + const title = template.title || "Untitled"; + const truncatedTitle = title.length > 30 ? title.substring(0, 30) + "..." : title; + return { id: template.id, title: truncatedTitle, fullTitle: template.title || "" }; + }) + ), + refetchOnWindowFocus: true, + }); + + const localLlmBaseUrl = useQuery({ + queryKey: ["local-llm"], + queryFn: async () => { + const { type, connection } = await connectorCommands.getLlmConnection(); + return type === "HyprLocal" ? connection.api_base : null; + }, + }); + + const handleRegenerateOrCancel = () => { + if (isEnhancing) { + // Cancel the enhancement + cancelEnhance(); + + // Cancel local LLM endpoint if available + if (localLlmBaseUrl.data) { + fetch(`${localLlmBaseUrl.data}/cancel`, { method: "GET" }); + } + } else { + // Start enhancement + if (onEnhance) { + onEnhance({ triggerType: "manual" }); + } + } + }; + + const handleRegenerateWithTemplate = (templateId: string) => { + if (onEnhance) { + const actualTemplateId = templateId === "auto" ? null : templateId; + onEnhance({ triggerType: "template", templateId: actualTemplateId }); + } + setIsTemplateDropdownOpen(false); + }; + + const handleAddTemplate = async () => { + setIsTemplateDropdownOpen(false); + try { + await windowsCommands.windowShow({ type: "settings" }); + await windowsCommands.windowNavigate({ type: "settings" }, "/app/settings?tab=templates"); + } catch (error) { + console.error("Failed to open settings/templates:", error); + } + }; + + // Commented out share functionality + // const handleShareOpenChange = (newOpen: boolean) => { + // setIsShareDropdownOpen(newOpen); + // if (hasEnhancedNote) { + // handleOpenStateChange(newOpen); + // } + // }; + + const shouldShowProgress = showProgress && progress < 1.0; + + // Helper function to extract emoji and clean name (copied from floating-button.tsx) + const extractEmojiAndName = (title: string) => { + if (!title) { + return { + emoji: "📄", + name: "Untitled", + }; + } + const emojiMatch = title.match(/^(\p{Emoji})\s*/u); + if (emojiMatch) { + return { + emoji: emojiMatch[1], + name: title.replace(/^(\p{Emoji})\s*/u, "").trim(), + }; + } + + // Fallback emoji based on keywords if no emoji in title + const lowercaseTitle = title.toLowerCase(); + let fallbackEmoji = "📄"; + if (lowercaseTitle.includes("meeting")) { + fallbackEmoji = "💼"; + } + if (lowercaseTitle.includes("interview")) { + fallbackEmoji = "👔"; + } + if (lowercaseTitle.includes("standup")) { + fallbackEmoji = "☀️"; + } + if (lowercaseTitle.includes("review")) { + fallbackEmoji = "📝"; + } + + return { + emoji: fallbackEmoji, + name: title, + }; + }; + + return ( +
+ {/* Regenerate button */} +
+ { + /* Share button + + + + + + + + + */ + } + + {/* Regenerate button with template dropdown */} + + + + + + {/* Commented out separate chevron button */} + { + /* + + + + */ + } + + +
+ {/* Add Template option */} +
+ + Add Template +
+ + {/* Separator */} +
+ + {/* Default option */} +
handleRegenerateWithTemplate("auto")} + > + + No Template (Default) +
+ + {/* Custom templates */} + {templatesQuery.data && templatesQuery.data.length > 0 && ( + <> +
+ {templatesQuery.data.map((template) => { + const { emoji, name } = extractEmojiAndName(template.fullTitle); + + return ( +
handleRegenerateWithTemplate(template.id)} + title={template.fullTitle} // Show full title on hover + > + {emoji} + {name} +
+ ); + })} + + )} +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/note-header/sub-headers/transcript-sub-header.tsx b/apps/desktop/src/components/editor-area/note-header/sub-headers/transcript-sub-header.tsx new file mode 100644 index 0000000000..7a6852e659 --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/sub-headers/transcript-sub-header.tsx @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; +import { AudioLinesIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { commands as miscCommands } from "@hypr/plugin-misc"; +import { type TranscriptEditorRef } from "@hypr/tiptap/transcript"; +import { Button } from "@hypr/ui/components/ui/button"; + +interface TranscriptSubHeaderProps { + sessionId: string; + editorRef?: React.RefObject; +} + +export function TranscriptSubHeader({ sessionId, editorRef }: TranscriptSubHeaderProps) { + // Check if audio file exists for this session + const audioExist = useQuery({ + refetchInterval: 2500, + enabled: !!sessionId, + queryKey: ["audio", sessionId, "exist"], + queryFn: () => miscCommands.audioExist(sessionId), + }); + + const handleOpenAudio = useCallback(() => { + miscCommands.audioOpen(sessionId); + }, [sessionId]); + + // Removed handleSearch function as it's no longer needed + + return ( +
+ {/* Full-width rounded box containing chips and buttons */} +
+ { + /* +
+ + +
+ */ + } + + {/* Right side - Action buttons */} +
+ {/* Audio file button - only show if audio exists */} + {audioExist.data && ( + + )} + + {/* Copy button */} + { + /* + + */ + } +
+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/note-header/tab-header.tsx b/apps/desktop/src/components/editor-area/note-header/tab-header.tsx new file mode 100644 index 0000000000..221cb0ef19 --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/tab-header.tsx @@ -0,0 +1,129 @@ +import { useEnhancePendingState } from "@/hooks/enhance-pending"; +import { cn } from "@hypr/ui/lib/utils"; +import { useOngoingSession, useSession } from "@hypr/utils/contexts"; +import { forwardRef, useEffect, useImperativeHandle } from "react"; + +interface TabHeaderProps { + sessionId: string; + onEnhance?: (params: { triggerType: "manual" | "template"; templateId?: string | null }) => void; + isEnhancing?: boolean; + progress?: number; + showProgress?: boolean; +} + +export interface TabHeaderRef { + isVisible: boolean; +} + +export const TabHeader = forwardRef( + ({ sessionId, onEnhance, isEnhancing, progress = 0, showProgress = false }, ref) => { + const [activeTab, setActiveTab] = useSession(sessionId, (s) => [ + s.activeTab, + s.setActiveTab, + ]); + const session = useSession(sessionId, (s) => s.session); + + const ongoingSessionStatus = useOngoingSession((s) => s.status); + const ongoingSessionId = useOngoingSession((s) => s.sessionId); + + // Check if this is a meeting session (has transcript or is currently recording) + const hasTranscript = session.words && session.words.length > 0; + const isCurrentlyRecording = ongoingSessionStatus === "running_active" && ongoingSessionId === sessionId; + const isSessionInactive = ongoingSessionStatus === "inactive" || session.id !== ongoingSessionId; + const hasEnhancedMemo = !!session?.enhanced_memo_html; + + const canEnhanceTranscript = hasTranscript && isSessionInactive; + + // Keep the "meeting session" concept for overall tab visibility + const isMeetingSession = hasTranscript || isCurrentlyRecording || isEnhancing; + + // BUT use floating button logic for Enhanced tab visibility + const isEnhancePending = useEnhancePendingState(sessionId); + const shouldShowEnhancedTab = hasEnhancedMemo || isEnhancePending || canEnhanceTranscript; + + // Automatic tab switching logic following existing conventions + + useEffect(() => { + // When enhancement starts (immediately after recording ends) -> switch to enhanced note + if (isEnhancePending || (ongoingSessionStatus === "inactive" && hasTranscript && shouldShowEnhancedTab)) { + setActiveTab("enhanced"); + } + }, [isEnhancePending, ongoingSessionStatus, hasTranscript, shouldShowEnhancedTab, setActiveTab]); + + // Set default tab to 'raw' for blank notes (no meeting session) + useEffect(() => { + if (!isMeetingSession) { + setActiveTab("raw"); + } + }, [isMeetingSession, setActiveTab]); + + const handleTabClick = (tab: "raw" | "enhanced" | "transcript") => { + setActiveTab(tab); + }; + + // Expose visibility state via ref + useImperativeHandle(ref, () => ({ + isVisible: isMeetingSession ?? false, + }), [isMeetingSession]); + + // Don't render tabs at all for blank notes (no meeting session) + if (!isMeetingSession) { + return null; + } + + return ( +
+ {/* Tab container */} +
+
+
+ {/* Raw Note Tab */} + + {/* Enhanced Note Tab - show when session ended OR transcript exists OR enhanced memo exists */} + {shouldShowEnhancedTab && ( + + )} + + + + {/* Transcript Tab - always show */} + +
+
+
+
+ ); + }, +); diff --git a/apps/desktop/src/components/editor-area/note-header/tab-sub-header.tsx b/apps/desktop/src/components/editor-area/note-header/tab-sub-header.tsx new file mode 100644 index 0000000000..40496fd52f --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/tab-sub-header.tsx @@ -0,0 +1,45 @@ +import { type TranscriptEditorRef } from "@hypr/tiptap/transcript"; +import { useSession } from "@hypr/utils/contexts"; +import { EnhancedNoteSubHeader } from "./sub-headers/enhanced-note-sub-header"; + +interface TabSubHeaderProps { + sessionId: string; + onEnhance?: (params: { triggerType: "manual" | "template"; templateId?: string | null }) => void; + isEnhancing?: boolean; + transcriptEditorRef?: TranscriptEditorRef | null; + progress?: number; + showProgress?: boolean; + hashtags?: string[]; +} + +export function TabSubHeader( + { sessionId, onEnhance, isEnhancing, transcriptEditorRef, progress, showProgress, hashtags }: TabSubHeaderProps, +) { + const activeTab = useSession(sessionId, (s) => s.activeTab); + + // Conditionally render based on activeTab + if (activeTab === "enhanced") { + return ( + + ); + } + + /* + if (activeTab === 'transcript') { + return ; + } + */ + + if (activeTab === "raw") { + // Empty sub-header with same dimensions as enhanced tab for consistent layout + return
; + } + + return null; +} diff --git a/apps/desktop/src/components/editor-area/note-header/title-input.tsx b/apps/desktop/src/components/editor-area/note-header/title-input.tsx index 8188bd829b..4994f9cf30 100644 --- a/apps/desktop/src/components/editor-area/note-header/title-input.tsx +++ b/apps/desktop/src/components/editor-area/note-header/title-input.tsx @@ -1,5 +1,5 @@ import { useLingui } from "@lingui/react/macro"; -import { type ChangeEvent, type KeyboardEvent } from "react"; +import { type ChangeEvent, type KeyboardEvent, useEffect, useRef } from "react"; interface TitleInputProps { value: string; @@ -7,6 +7,7 @@ interface TitleInputProps { onNavigateToEditor?: () => void; editable?: boolean; isGenerating?: boolean; + autoFocus?: boolean; } export default function TitleInput({ @@ -15,8 +16,10 @@ export default function TitleInput({ onNavigateToEditor, editable, isGenerating = false, + autoFocus = false, }: TitleInputProps) { const { t } = useLingui(); + const inputRef = useRef(null); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === "Tab") { @@ -32,8 +35,19 @@ export default function TitleInput({ return t`Untitled`; }; + useEffect(() => { + if (autoFocus && editable && !isGenerating && inputRef.current) { + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 200); + + return () => clearTimeout(timeoutId); + } + }, [autoFocus, editable, isGenerating]); + return ( void; +} + +export function TranscriptViewer({ sessionId, onEditorRefChange }: TranscriptViewerProps) { + const { words, partialWords, finalWords, isLive } = useTranscript(sessionId); + const [isAtBottom, setIsAtBottom] = useState(true); + const scrollContainerRef = useRef(null); + const editorRef = useRef(null); + + // Notify parent when editor ref changes - check periodically for ref to be set + useEffect(() => { + // Initial notification + if (onEditorRefChange) { + onEditorRefChange(editorRef.current); + } + + // Check if ref gets set later + const checkInterval = setInterval(() => { + if (editorRef.current?.editor && onEditorRefChange) { + onEditorRefChange(editorRef.current); + clearInterval(checkInterval); + } + }, 100); + + return () => clearInterval(checkInterval); + }, [onEditorRefChange]); + + // Removed ongoingSession since we no longer show the start recording UI + + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = container; + const threshold = 100; + const atBottom = scrollHeight - scrollTop - clientHeight <= threshold; + setIsAtBottom(atBottom); + }, []); + + const scrollToBottom = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth", + }); + }, []); + + useEffect(() => { + if (words && words.length > 0 && !isLive) { + editorRef.current?.setWords(words); + if (isAtBottom && editorRef.current?.isNearBottom()) { + editorRef.current?.scrollToBottom(); + } + } + }, [words, isAtBottom, isLive]); + + // Auto-scroll for live transcript + useEffect(() => { + if (isLive && isAtBottom && scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [finalWords, partialWords, isAtBottom, isLive]); + + const handleUpdate = (words: Word2[]) => { + dbCommands.getSession({ id: sessionId }).then((session) => { + if (session) { + dbCommands.upsertSession({ ...session, words }); + } + }); + }; + + // Removed handleStartRecording since we no longer show the start recording UI + + // Show empty state when no words and not live - return blank instead of start recording UI + const showEmptyMessage = sessionId && words.length <= 0 && !isLive; + + if (showEmptyMessage) { + return
; + } + + // Show simple text for live transcript + if (isLive) { + return ( +
+
+
+ {/* Final words - confirmed text */} + + {finalWords.map(word => word.text).join(" ")} + + {/* Partial words - still being processed */} + {partialWords.length > 0 && ( + + {" "} + {partialWords.map(word => word.text).join(" ")} + + )} +
+
+ + {!isAtBottom && ( + + )} +
+ ); + } + + // Show editor for finished transcript + return ( +
+ +
+ ); +} + +// Speaker selector components (copied from transcript-view.tsx) +const SpeakerSelector = (props: SpeakerViewInnerProps) => { + return ; +}; + +const MemoizedSpeakerSelector = memo(({ + onSpeakerChange, + speakerId, + speakerIndex, + speakerLabel, +}: SpeakerViewInnerProps) => { + const { userId } = useHypr(); + const [isOpen, setIsOpen] = useState(false); + const [speakerRange, setSpeakerRange] = useState("current"); + const inactive = useOngoingSession(s => s.status === "inactive"); + const [human, setHuman] = useState(null); + + const noteMatch = useMatch({ from: "/app/note/$id", shouldThrow: false }); + const sessionId = noteMatch?.params.id; + + const { data: participants = [] } = useQuery({ + enabled: !!sessionId, + queryKey: ["participants", sessionId!, "selector"], + queryFn: () => dbCommands.sessionListParticipants(sessionId!), + }); + + useEffect(() => { + if (human) { + onSpeakerChange(human, speakerRange); + } + }, [human, speakerRange]); + + useEffect(() => { + const foundHuman = participants.find((s) => s.id === speakerId); + + if (foundHuman) { + setHuman(foundHuman); + } + }, [participants, speakerId]); + + const handleClickHuman = (human: Human) => { + setHuman(human); + setIsOpen(false); + }; + + if (!sessionId) { + return

; + } + + if (!inactive) { + return

; + } + + const getDisplayName = (human: Human | null) => { + if (human) { + if (human.id === userId && !human.full_name) { + return "You"; + } + + if (human.full_name) { + return human.full_name; + } + } + + return getSpeakerLabel({ + [SPEAKER_INDEX_ATTR]: speakerIndex, + [SPEAKER_ID_ATTR]: speakerId, + [SPEAKER_LABEL_ATTR]: speakerLabel ?? null, + }); + }; + + return ( +
+ + + + + +
+
+ +
+ + +
+
+
+
+ ); +}); + +interface SpeakerRangeSelectorProps { + value: SpeakerChangeRange; + onChange: (value: SpeakerChangeRange) => void; +} + +function SpeakerRangeSelector({ value, onChange }: SpeakerRangeSelectorProps) { + const options = [ + { value: "current" as const, label: "Just this" }, + { value: "all" as const, label: "Replace all" }, + { value: "fromHere" as const, label: "From here" }, + ]; + + return ( +
+

Apply speaker change to:

+
+ {options.map((option) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/components/pro-gate-modal/index.tsx b/apps/desktop/src/components/pro-gate-modal/index.tsx index 3c9b6f39d8..cf8713e6ba 100644 --- a/apps/desktop/src/components/pro-gate-modal/index.tsx +++ b/apps/desktop/src/components/pro-gate-modal/index.tsx @@ -46,7 +46,7 @@ export function ProGateModal({ isOpen, onClose, type }: ProGateModalProps) { path: params.to, search: params.search, }); - }, 500); + }, 800); }); }; diff --git a/apps/desktop/src/components/right-panel/components/chat-model-info-modal.tsx b/apps/desktop/src/components/right-panel/components/chat-model-info-modal.tsx new file mode 100644 index 0000000000..2626597b19 --- /dev/null +++ b/apps/desktop/src/components/right-panel/components/chat-model-info-modal.tsx @@ -0,0 +1,134 @@ +import { Brain, BrainCircuit, Cpu, HardDrive, X } from "lucide-react"; + +import { commands as windowsCommands } from "@hypr/plugin-windows"; +import { Button } from "@hypr/ui/components/ui/button"; +import { Modal, ModalBody, ModalDescription, ModalTitle } from "@hypr/ui/components/ui/modal"; + +interface ChatModelInfoModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function ChatModelInfoModal({ isOpen, onClose }: ChatModelInfoModalProps) { + const handleClose = () => { + onClose(); + }; + + const handleChooseModel = () => { + windowsCommands.windowShow({ type: "settings" }).then(() => { + setTimeout(() => { + windowsCommands.windowEmitNavigate({ type: "settings" }, { + path: "/app/settings", + search: { tab: "ai-llm" }, + }); + }, 800); + }); + handleClose(); + }; + + if (!isOpen) { + return null; + } + + return ( + <> +
+ + +
+ + + +
+ + Which model should I use for Chat? + +
+ + + {" "} + based on your priorities: + + + {/* Model tiers diagram */} +
+ {/* Ultimate */} +
+ +
+
+ Ultimate + HyprCloud +
+
Optimized, tool calling, MCP, web search, etc
+
+
+ + {/* Advanced */} +
+ +
+
+ Advanced + GPT-4.1, Sonnet, GPT-4o, GPT-5 +
+
Tool calling, MCP
+
+
+ + {/* Standard */} +
+ +
+
+ Standard + Other custom endpoint cloud models +
+
+
+ + {/* Baseline */} +
+ +
+
+ Basic + Local models +
+
+
+
+ + {/* Close button */} +
+ +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx b/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx index 47408024ae..e33f30d558 100644 --- a/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx +++ b/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx @@ -1,16 +1,18 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowUpIcon, BuildingIcon, FileTextIcon, Square, UserIcon } from "lucide-react"; -import { useCallback, useEffect, useRef } from "react"; +import { ArrowUpIcon, BrainIcon, BuildingIcon, FileTextIcon, Square, UserIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } 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 connectorCommands } from "@hypr/plugin-connector"; 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"; +import { ChatModelInfoModal } from "../chat-model-info-modal"; interface ChatInputProps { inputValue: string; @@ -45,6 +47,28 @@ export function ChatInput( ) { const { userId } = useHypr(); const { chatInputRef, pendingSelection, clearPendingSelection } = useRightPanel(); + const [isModelModalOpen, setIsModelModalOpen] = useState(false); + + // Get current LLM connection and model info + const llmConnectionQuery = useQuery({ + queryKey: ["llm-connection"], + queryFn: () => connectorCommands.getLlmConnection(), + refetchOnWindowFocus: true, + refetchInterval: 5000, + }); + + const customLlmModelQuery = useQuery({ + queryKey: ["custom-llm-model"], + queryFn: () => connectorCommands.getCustomLlmModel(), + enabled: llmConnectionQuery.data?.type === "Custom", + refetchInterval: 5000, + }); + + const hyprCloudEnabledQuery = useQuery({ + queryKey: ["hypr-cloud-enabled"], + queryFn: () => connectorCommands.getHyprcloudEnabled(), + refetchInterval: 5000, + }); const lastBacklinkSearchTime = useRef(0); @@ -361,8 +385,42 @@ export function ChatInput( const entityTitle = getEntityTitle(); + const getCurrentModelName = () => { + const connectionType = llmConnectionQuery.data?.type; + const isHyprCloudEnabled = hyprCloudEnabledQuery.data; + + if (isHyprCloudEnabled) { + return "HyprCloud"; + } + + switch (connectionType) { + case "Custom": + return customLlmModelQuery.data || "Custom Model"; + case "HyprLocal": + return "Local LLM"; + return "HyprLLM"; + default: + return "Model"; + } + }; + return (
+ {/* Note badge at top */} + {entityId && ( +
+ +
+ {getBadgeIcon()} +
+ {entityTitle} +
+
+ )} + {/* Custom styles to disable rich text features */}