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 000000000..95606ec74 --- /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 068ae49f4..ea4a67f0d 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -1,8 +1,7 @@ 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"; @@ -19,6 +18,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 +26,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,6 +127,7 @@ 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 [rawContent, setRawContent] = useSession(sessionId, (s) => [ @@ -138,11 +141,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 +164,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 +194,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, @@ -226,15 +242,6 @@ export default function EditorArea({ [showRaw, enhancedContent, rawContent], ); - 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 +292,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 000000000..73c78f06a --- /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 000000000..c1cfcd686 --- /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 685e09076..0622e5c36 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 f8e6a66a1..84324155d 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 1ecc4a6f6..c3c3f555b 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 f54a6933b..adfca9241 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 afd4f6f50..b4de8a894 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 000000000..6f6ef8cb6 --- /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 000000000..7a6852e65 --- /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 000000000..8c2a629e2 --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/tab-header.tsx @@ -0,0 +1,127 @@ +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 000000000..40496fd52 --- /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 8188bd829..4994f9cf3 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, 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) { + editorRef.current?.setWords(words); + if (isAtBottom && editorRef.current?.isNearBottom()) { + editorRef.current?.scrollToBottom(); + } + } + }, [words, isAtBottom]); + + 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 ( +
+
+
+ {words.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/right-panel/components/chat/chat-input.tsx b/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx index 47408024a..743a50ce3 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 @@ -409,7 +409,7 @@ export function ChatInput( display: none !important; } .chat-editor:not(.has-content) .tiptap-normal .is-empty::before { - content: "Ask anything about this note..." !important; + content: "Ask anything, @ to add contexts..." !important; float: left; color: #9ca3af; pointer-events: none; @@ -441,7 +441,7 @@ export function ChatInput( }} /> {isGenerating && !inputValue.trim() && ( -
Ask anything about this note...
+
Type @(person/note name) to add context...
)}
diff --git a/apps/desktop/src/components/right-panel/index.tsx b/apps/desktop/src/components/right-panel/index.tsx index 0ce880306..02ebae764 100644 --- a/apps/desktop/src/components/right-panel/index.tsx +++ b/apps/desktop/src/components/right-panel/index.tsx @@ -1,10 +1,10 @@ import { useRightPanel } from "@/contexts"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { ResizablePanel } from "@hypr/ui/components/ui/resizable"; -import { ChatView, TranscriptView } from "./views"; +import { ChatView } from "./views"; export default function RightPanel() { - const { isExpanded, currentView } = useRightPanel(); + const { isExpanded } = useRightPanel(); const show = getCurrentWebviewWindowLabel() === "main" && isExpanded; if (!show) { @@ -17,7 +17,8 @@ export default function RightPanel() { maxSize={50} className="h-full border-l bg-neutral-50 overflow-hidden" > - {(currentView === "transcript") ? : } + {/*{(currentView === "transcript") ? : }*/} + ); } diff --git a/apps/desktop/src/components/right-panel/views/chat-view.tsx b/apps/desktop/src/components/right-panel/views/chat-view.tsx index 8ce8cc231..b01d165eb 100644 --- a/apps/desktop/src/components/right-panel/views/chat-view.tsx +++ b/apps/desktop/src/components/right-panel/views/chat-view.tsx @@ -268,60 +268,81 @@ export function ChatView() { if (showHistory) { return ( - +
+ {/* Reserved space for floating buttons */} +
+ +
+ + {/* Chat content starts below reserved space */} +
+ +
+
); } return ( -
- - - {messages.length === 0 - ? ( - - ) - : ( - - )} - - - handleSubmit(mentionedContent, selectionData, htmlContent)} - onKeyDown={handleKeyDown} - autoFocus={true} - entityId={activeEntity?.id} - entityType={activeEntity?.type} - onNoteBadgeClick={handleNoteBadgeClick} - isGenerating={isGenerating} - onStop={handleStop} - /> +
+ {/* Reserved space for floating buttons */} +
+ +
+ + {/* Chat content starts below reserved space */} +
+ {messages.length === 0 + ? ( + + ) + : ( + + )} + + + handleSubmit(mentionedContent, selectionData, htmlContent)} + onKeyDown={handleKeyDown} + autoFocus={true} + entityId={activeEntity?.id} + entityType={activeEntity?.type} + onNoteBadgeClick={handleNoteBadgeClick} + isGenerating={isGenerating} + onStop={handleStop} + /> +
); } diff --git a/apps/desktop/src/components/search-bar.tsx b/apps/desktop/src/components/search-bar.tsx index 298ff132f..f970b526f 100644 --- a/apps/desktop/src/components/search-bar.tsx +++ b/apps/desktop/src/components/search-bar.tsx @@ -118,6 +118,7 @@ export function SearchBar() { "hover:bg-white", isFocused && "bg-white", "transition-colors duration-200", + "cursor-pointer", ])} onClick={() => setShowCommandPalette(true)} > @@ -141,7 +142,8 @@ export function SearchBar() { }, 150); }} placeholder={t`Search...`} - className="flex-1 bg-transparent outline-none text-xs" + className="flex-1 bg-transparent outline-none text-xs pointer-events-none" + disabled /> {/* Tag selector */} { diff --git a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx index eb3e2bc1d..7aa92eeca 100644 --- a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx +++ b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx @@ -10,7 +10,7 @@ import { cn } from "@hypr/ui/lib/utils"; import { SearchBar } from "../../search-bar"; import { ChatPanelButton } from "../buttons/chat-panel-button"; import { LeftSidebarButton } from "../buttons/left-sidebar-button"; -import { TranscriptPanelButton } from "../buttons/transcript-panel-button"; +import { ShareButton } from "../buttons/share-button"; export function MainToolbar() { const noteMatch = useMatch({ from: "/app/note/$id", shouldThrow: false }); @@ -57,9 +57,9 @@ export function MainToolbar() { {isMain && ( <> {(organizationMatch || humanMatch) && } - {/*isNote && */} + {isNote && } - + {/**/} )}
diff --git a/apps/desktop/src/contexts/right-panel.tsx b/apps/desktop/src/contexts/right-panel.tsx index e2533ee75..b0af37acb 100644 --- a/apps/desktop/src/contexts/right-panel.tsx +++ b/apps/desktop/src/contexts/right-panel.tsx @@ -36,7 +36,7 @@ export function RightPanelProvider({ children: React.ReactNode; }) { const [isExpanded, setIsExpanded] = useState(true); - const [currentView, setCurrentView] = useState("transcript"); + const [currentView, setCurrentView] = useState("chat"); const [pendingSelection, setPendingSelection] = useState(null); const previouslyFocusedElement = useRef(null); const chatInputRef = useRef(null); diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 8ac8ee305..3529cefa6 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -613,7 +613,7 @@ msgstr "Full name" msgid "General" msgstr "General" -#: src/components/editor-area/note-header/title-input.tsx:30 +#: src/components/editor-area/note-header/title-input.tsx:33 msgid "Generating title..." msgstr "Generating title..." @@ -694,7 +694,7 @@ msgstr "It's ok to move on, downloads will continue in the background" msgid "Job title" msgstr "Job title" -#: src/components/editor-area/note-header/chips/event-chip.tsx:199 +#: src/components/editor-area/note-header/chips/event-chip.tsx:201 msgid "Join meeting" msgstr "Join meeting" @@ -722,7 +722,7 @@ msgstr "LinkedIn username" msgid "Loading available models..." msgstr "Loading available models..." -#: src/components/editor-area/note-header/chips/event-chip.tsx:466 +#: src/components/editor-area/note-header/chips/event-chip.tsx:470 msgid "Loading events..." msgstr "Loading events..." @@ -813,7 +813,7 @@ msgstr "No language found." msgid "No members found" msgstr "No members found" -#: src/components/editor-area/note-header/chips/event-chip.tsx:474 +#: src/components/editor-area/note-header/chips/event-chip.tsx:478 msgid "No past events found." msgstr "No past events found." @@ -960,7 +960,7 @@ msgstr "Role" msgid "Save audio recording locally alongside the transcript." msgstr "Save audio recording locally alongside the transcript." -#: src/components/editor-area/note-header/chips/event-chip.tsx:595 +#: src/components/editor-area/note-header/chips/event-chip.tsx:599 msgid "Save Date" msgstr "Save Date" @@ -968,7 +968,7 @@ msgstr "Save Date" msgid "Save recordings" msgstr "Save recordings" -#: src/components/editor-area/note-header/chips/event-chip.tsx:595 +#: src/components/editor-area/note-header/chips/event-chip.tsx:599 msgid "Saving..." msgstr "Saving..." @@ -981,7 +981,7 @@ msgstr "Search names or emails" msgid "Search templates..." msgstr "Search templates..." -#: src/components/search-bar.tsx:143 +#: src/components/search-bar.tsx:144 msgid "Search..." msgstr "Search..." @@ -1185,7 +1185,7 @@ msgstr "Type or paste in emails below, separated by commas. Your workspace will msgid "Type to search..." msgstr "Type to search..." -#: src/components/editor-area/note-header/title-input.tsx:32 +#: src/components/editor-area/note-header/title-input.tsx:35 msgid "Untitled" msgstr "Untitled" @@ -1229,7 +1229,7 @@ msgstr "Vault Name" msgid "View calendar" msgstr "View calendar" -#: src/components/editor-area/note-header/chips/event-chip.tsx:209 +#: src/components/editor-area/note-header/chips/event-chip.tsx:211 #: src/components/left-sidebar/events-list.tsx:193 msgid "View in calendar" msgstr "View in calendar" diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 7fb3d5b43..1eb4376df 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -608,7 +608,7 @@ msgstr "" msgid "General" msgstr "" -#: src/components/editor-area/note-header/title-input.tsx:30 +#: src/components/editor-area/note-header/title-input.tsx:33 msgid "Generating title..." msgstr "" @@ -689,7 +689,7 @@ msgstr "" msgid "Job title" msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:199 +#: src/components/editor-area/note-header/chips/event-chip.tsx:201 msgid "Join meeting" msgstr "" @@ -717,7 +717,7 @@ msgstr "" msgid "Loading available models..." msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:466 +#: src/components/editor-area/note-header/chips/event-chip.tsx:470 msgid "Loading events..." msgstr "" @@ -808,7 +808,7 @@ msgstr "" msgid "No members found" msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:474 +#: src/components/editor-area/note-header/chips/event-chip.tsx:478 msgid "No past events found." msgstr "" @@ -955,7 +955,7 @@ msgstr "" msgid "Save audio recording locally alongside the transcript." msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:595 +#: src/components/editor-area/note-header/chips/event-chip.tsx:599 msgid "Save Date" msgstr "" @@ -963,7 +963,7 @@ msgstr "" msgid "Save recordings" msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:595 +#: src/components/editor-area/note-header/chips/event-chip.tsx:599 msgid "Saving..." msgstr "" @@ -976,7 +976,7 @@ msgstr "" msgid "Search templates..." msgstr "" -#: src/components/search-bar.tsx:143 +#: src/components/search-bar.tsx:144 msgid "Search..." msgstr "" @@ -1180,7 +1180,7 @@ msgstr "" msgid "Type to search..." msgstr "" -#: src/components/editor-area/note-header/title-input.tsx:32 +#: src/components/editor-area/note-header/title-input.tsx:35 msgid "Untitled" msgstr "" @@ -1224,7 +1224,7 @@ msgstr "" msgid "View calendar" msgstr "" -#: src/components/editor-area/note-header/chips/event-chip.tsx:209 +#: src/components/editor-area/note-header/chips/event-chip.tsx:211 #: src/components/left-sidebar/events-list.tsx:193 msgid "View in calendar" msgstr "" diff --git a/apps/desktop/src/routes/app.note.$id.tsx b/apps/desktop/src/routes/app.note.$id.tsx index cd09b5f30..99b5a9792 100644 --- a/apps/desktop/src/routes/app.note.$id.tsx +++ b/apps/desktop/src/routes/app.note.$id.tsx @@ -161,7 +161,7 @@ function Component() { )}
-
+
diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index e2d66dfd1..406de8a81 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -133,3 +133,16 @@ body { transform: translateY(-10px); } } + +/* Search result highlighting styles */ +.search-result { + background-color: #ffeb3b; + border-radius: 2px; + padding: 1px 0; +} + +.search-result-current { + background-color: #31e054 !important; + border-radius: 2px; + padding: 1px 0; +} diff --git a/packages/tiptap/src/editor/index.tsx b/packages/tiptap/src/editor/index.tsx index 688226302..41fdb8364 100644 --- a/packages/tiptap/src/editor/index.tsx +++ b/packages/tiptap/src/editor/index.tsx @@ -1,6 +1,7 @@ import "../styles/tiptap.css"; import "../styles/mention.css"; +import Document from "@tiptap/extension-document"; import { type Editor as TiptapEditor, EditorContent, type HTMLContent, useEditor } from "@tiptap/react"; import { forwardRef, useEffect, useRef } from "react"; @@ -32,6 +33,7 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( const editor = useEditor({ extensions: [ ...shared.extensions, + Document, mention(mentionConfig), ], editable, diff --git a/packages/tiptap/src/shared/extensions.ts b/packages/tiptap/src/shared/extensions.ts index 54fbc2f5e..8a1af90c7 100644 --- a/packages/tiptap/src/shared/extensions.ts +++ b/packages/tiptap/src/shared/extensions.ts @@ -7,6 +7,7 @@ import TaskList from "@tiptap/extension-task-list"; import Underline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; +import { SearchAndReplace } from "../transcript/extensions/search-and-replace"; import { AIHighlight } from "./ai-highlight"; import { StreamingAnimation } from "./animation"; import { ClipboardTextSerializer } from "./clipboard"; @@ -89,4 +90,8 @@ export const extensions = [ CustomListKeymap, StreamingAnimation, ClipboardTextSerializer, + SearchAndReplace.configure({ + searchResultClass: "search-result", + disableRegex: true, + }), ]; diff --git a/packages/tiptap/src/styles/transcript.css b/packages/tiptap/src/styles/transcript.css index 7afcfb1b5..e2155d280 100644 --- a/packages/tiptap/src/styles/transcript.css +++ b/packages/tiptap/src/styles/transcript.css @@ -31,8 +31,9 @@ .tiptap-transcript { font-size: 15px; line-height: 1.7; - white-space: normal; - word-wrap: break-word; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; color: #333; text-align: left; } diff --git a/packages/tiptap/src/transcript/index.tsx b/packages/tiptap/src/transcript/index.tsx index 911405678..933bf5895 100644 --- a/packages/tiptap/src/transcript/index.tsx +++ b/packages/tiptap/src/transcript/index.tsx @@ -182,9 +182,9 @@ const TranscriptEditor = forwardRef(
- +
); diff --git a/packages/ui/src/components/ui/command.tsx b/packages/ui/src/components/ui/command.tsx index 00c39ccac..53ff0e85d 100644 --- a/packages/ui/src/components/ui/command.tsx +++ b/packages/ui/src/components/ui/command.tsx @@ -69,7 +69,7 @@ const CommandDialog = ({ aria-modal="true" className={cn( "fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", - "w-[90vw] max-w-[450px]", + "w-[2000px] max-w-[450px]", "overflow-hidden rounded-lg bg-background shadow-lg", )} > diff --git a/packages/utils/src/stores/session.ts b/packages/utils/src/stores/session.ts index d089bce57..4f3dc32e8 100644 --- a/packages/utils/src/stores/session.ts +++ b/packages/utils/src/stores/session.ts @@ -7,12 +7,14 @@ import pDebounce from "p-debounce"; type State = { session: Session; showRaw: boolean; + activeTab: "raw" | "enhanced" | "transcript"; }; type Actions = { get: () => State & Actions; refresh: () => Promise; setShowRaw: (showRaw: boolean) => void; + setActiveTab: (tab: "raw" | "enhanced" | "transcript") => void; updateTitle: (title: string) => void; updatePreMeetingNote: (note: string) => void; updateRawNote: (note: string) => void; @@ -26,6 +28,7 @@ export const createSessionStore = (session: Session) => { return createStore((set, get) => ({ session, showRaw: !session.enhanced_memo_html, + activeTab: "raw", get, refresh: async () => { const { session: { id } } = get(); @@ -41,6 +44,20 @@ export const createSessionStore = (session: Session) => { }) ); }, + setActiveTab: (tab: "raw" | "enhanced" | "transcript") => { + set((state) => + mutate(state, (draft) => { + draft.activeTab = tab; + // Keep showRaw in sync for backward compatibility + if (tab === "raw") { + draft.showRaw = true; + } else if (tab === "enhanced") { + draft.showRaw = false; + } + // transcript doesn't affect showRaw + }) + ); + }, updateTitle: (title: string) => { set((state) => { const next = mutate(state, (draft) => {