From b7e4e118860fae3141b4a41e73e455b3aa6aef5c Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 30 Mar 2026 14:16:57 -0700 Subject: [PATCH 1/2] fix: add note tab context menus Add native context menus for memo, transcript, and enhanced note tabs, including copy/regenerate actions and template-only removal. --- .../session/components/note-input/header.tsx | 526 ++++++++++++++---- packages/ui/src/components/ui/hover-card.tsx | 12 +- packages/ui/src/components/ui/note-tab.tsx | 29 +- 3 files changed, 446 insertions(+), 121 deletions(-) diff --git a/apps/desktop/src/session/components/note-input/header.tsx b/apps/desktop/src/session/components/note-input/header.tsx index a2c4df75c8..4a46c8e777 100644 --- a/apps/desktop/src/session/components/note-input/header.tsx +++ b/apps/desktop/src/session/components/note-input/header.tsx @@ -18,6 +18,12 @@ import { type AttachmentInfo, commands as fsSyncCommands, } from "@hypr/plugin-fs-sync"; +import { json2md, parseJsonContent } from "@hypr/tiptap/shared"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@hypr/ui/components/ui/hover-card"; import { NoteTab } from "@hypr/ui/components/ui/note-tab"; import { AppFloatingPanel, @@ -30,6 +36,7 @@ import { useScrollFade, } from "@hypr/ui/components/ui/scroll-fade"; import { Spinner } from "@hypr/ui/components/ui/spinner"; +import { sonnerToast } from "@hypr/ui/components/ui/toast"; import { Tooltip, TooltipContent, @@ -49,6 +56,10 @@ import { extractPlainText } from "~/search/contexts/engine/utils"; import { getEnhancerService } from "~/services/enhancer"; import { useHasTranscript } from "~/session/components/shared"; import { useEnsureDefaultSummary } from "~/session/hooks/useEnhancedNotes"; +import { + type MenuItemDef, + useNativeContextMenu, +} from "~/shared/hooks/useNativeContextMenu"; import { useWebResources } from "~/shared/ui/resource-list"; import * as main from "~/store/tinybase/store/main"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; @@ -79,6 +90,91 @@ function TruncatedTitle({ ); } +function getStoredNoteMarkdown(content: string | undefined) { + const trimmed = content?.trim() ?? ""; + + if (!trimmed) { + return ""; + } + + if (!trimmed.startsWith("{")) { + return trimmed; + } + + return json2md(parseJsonContent(trimmed)).trim(); +} + +async function copyTextToClipboard( + text: string, + messages?: { + success: string; + error: string; + }, +) { + try { + await navigator.clipboard.writeText(text); + + if (messages) { + sonnerToast.success(messages.success); + } + + return true; + } catch (error) { + console.error("Failed to copy note tab content", error); + + if (messages) { + sonnerToast.error(messages.error); + } + + return false; + } +} + +function HeaderTabRaw({ + isActive, + onClick = () => {}, + sessionId, +}: { + isActive: boolean; + onClick?: () => void; + sessionId: string; +}) { + const rawMd = main.UI.useCell( + "sessions", + sessionId, + "raw_md", + main.STORE_ID, + ) as string | undefined; + const memoMarkdown = useMemo(() => getStoredNoteMarkdown(rawMd), [rawMd]); + const contextMenu = useMemo( + () => [ + { + id: `copy-memo-${sessionId}`, + text: "Copy", + action: () => { + void copyTextToClipboard(memoMarkdown, { + success: "Memo copied to clipboard", + error: "Failed to copy memo", + }); + }, + disabled: memoMarkdown.length === 0, + }, + ], + [memoMarkdown, sessionId], + ); + const showContextMenu = useNativeContextMenu(contextMenu); + + return ( + + Memos + + ); +} + function HeaderTabTranscript({ isActive, onClick = () => {}, @@ -120,94 +216,133 @@ function HeaderTabTranscript({ }; }, []); - const handleRefreshClick = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); + const handleRefresh = useCallback(async () => { + if (!audioExists || isBatchProcessing || !store) { + return; + } - if (!audioExists || isBatchProcessing || !store) { - return; + setIsRedoing(true); + + const oldTranscriptIds: string[] = []; + store.forEachRow("transcripts", (transcriptId, _forEachCell) => { + const session = store.getCell("transcripts", transcriptId, "session_id"); + if (session === sessionId) { + oldTranscriptIds.push(transcriptId); } + }); - setIsRedoing(true); + getEnhancerService()?.resetEnhanceTasks(sessionId); - const oldTranscriptIds: string[] = []; - store.forEachRow("transcripts", (transcriptId, _forEachCell) => { - const session = store.getCell( - "transcripts", - transcriptId, - "session_id", - ); - if (session === sessionId) { - oldTranscriptIds.push(transcriptId); - } - }); + try { + const result = await fsSyncCommands.audioPath(sessionId); + if (result.status === "error") { + throw new Error(result.error); + } - getEnhancerService()?.resetEnhanceTasks(sessionId); + const audioPath = result.data; + if (!audioPath) { + throw new Error("audio path not available"); + } - try { - const result = await fsSyncCommands.audioPath(sessionId); - if (result.status === "error") { - throw new Error(result.error); - } + await runBatch(audioPath); - const audioPath = result.data; - if (!audioPath) { - throw new Error("audio path not available"); - } + if (oldTranscriptIds.length > 0) { + store.transaction(() => { + oldTranscriptIds.forEach((id) => { + store.delRow("transcripts", id); + }); + }); + } - await runBatch(audioPath); + getEnhancerService()?.queueAutoEnhance(sessionId); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : JSON.stringify(error); + console.error("[redo_transcript] failed:", message); + } finally { + setIsRedoing(false); + } + }, [audioExists, isBatchProcessing, runBatch, sessionId, store]); + const handleCopy = useCallback( + async (messages?: { success: string; error: string }) => { + if (!transcriptText) { + return false; + } - if (oldTranscriptIds.length > 0) { - store.transaction(() => { - oldTranscriptIds.forEach((id) => { - store.delRow("transcripts", id); - }); - }); - } + const copiedToClipboard = await copyTextToClipboard( + transcriptText, + messages, + ); + if (!copiedToClipboard) { + return false; + } - getEnhancerService()?.queueAutoEnhance(sessionId); - } catch (error) { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : JSON.stringify(error); - console.error("[redo_transcript] failed:", message); - } finally { - setIsRedoing(false); + if (copiedResetTimeoutRef.current !== null) { + window.clearTimeout(copiedResetTimeoutRef.current); } + + setCopied(true); + copiedResetTimeoutRef.current = window.setTimeout(() => { + setCopied(false); + copiedResetTimeoutRef.current = null; + }, 2000); + + return true; + }, + [transcriptText], + ); + const handleRefreshClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + void handleRefresh(); }, - [audioExists, isBatchProcessing, runBatch, sessionId, store], + [handleRefresh], ); const handleCopyClick = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (!transcriptText) { - return; - } - - try { - await navigator.clipboard.writeText(transcriptText); - if (copiedResetTimeoutRef.current !== null) { - window.clearTimeout(copiedResetTimeoutRef.current); - } - setCopied(true); - copiedResetTimeoutRef.current = window.setTimeout(() => { - setCopied(false); - copiedResetTimeoutRef.current = null; - }, 2000); - } catch {} + await handleCopy(); }, - [transcriptText], + [handleCopy], ); + const canRegenerate = audioExists && isSessionInactive && !!store; + const canCopy = transcriptText.length > 0; const showRefreshButton = audioExists && isActive && isSessionInactive; const showCopyButton = isActive && isSessionInactive && transcriptText.length > 0; const showProgress = audioExists && isActive && isProcessing; + const contextMenu = useMemo( + () => [ + { + id: `copy-transcript-${sessionId}`, + text: "Copy", + action: () => { + void handleCopy({ + success: "Transcript copied to clipboard", + error: "Failed to copy transcript", + }); + }, + disabled: !canCopy, + }, + { + id: `regenerate-transcript-${sessionId}`, + text: "Regenerate", + action: () => { + void handleRefresh(); + }, + disabled: !canRegenerate, + }, + ], + [canCopy, canRegenerate, handleCopy, handleRefresh, sessionId], + ); + const showContextMenu = useNativeContextMenu(contextMenu); const refreshButton = ( + Transcript {showCopyButton && ( @@ -276,27 +415,160 @@ function HeaderTabEnhanced({ onClick = () => {}, sessionId, enhancedNoteId, + canRemove = false, + onRemove, }: { isActive: boolean; onClick?: () => void; sessionId: string; enhancedNoteId: string; + canRemove?: boolean; + onRemove?: () => void; }) { const { isGenerating, isError, onRegenerate, onCancel, currentStep } = useEnhanceLogic(sessionId, enhancedNoteId); - - const title = - main.UI.useCell("enhanced_notes", enhancedNoteId, "title", main.STORE_ID) || - "Summary"; - + const content = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "content", + main.STORE_ID, + ) as string | undefined; + const templateId = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "template_id", + main.STORE_ID, + ) as string | undefined; + const rawTemplateTitle = main.UI.useCell( + "templates", + templateId ?? "", + "title", + main.STORE_ID, + ); + const templateTitle = + typeof rawTemplateTitle === "string" && rawTemplateTitle.trim() + ? rawTemplateTitle.trim() + : null; + const openTemplatesTab = useOpenTemplatesTab(); + const summaryTitle = "Summary"; + const tabLabel = templateTitle ?? summaryTitle; + const noteMarkdown = useMemo(() => getStoredNoteMarkdown(content), [content]); + + const handleCopy = useCallback(() => { + return copyTextToClipboard(noteMarkdown, { + success: `${tabLabel} copied to clipboard`, + error: `Failed to copy ${tabLabel}`, + }); + }, [noteMarkdown, tabLabel]); + const handleRegenerate = useCallback(() => { + void onRegenerate(null); + }, [onRegenerate]); const handleRegenerateClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - void onRegenerate(null); + handleRegenerate(); + }, + [handleRegenerate], + ); + const handleExploreTemplatesClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!templateId) { + return; + } + + openTemplatesTab({ + showHomepage: false, + isWebMode: false, + selectedMineId: templateId, + selectedWebIndex: null, + }); }, - [onRegenerate], + [openTemplatesTab, templateId], ); + const wrapWithTemplateTooltip = useCallback( + (node: React.ReactNode) => { + if (!templateId || !templateTitle) { + return node; + } + + return ( + + {node} + + +

+ + {templateTitle} + {" "} + was used to generate this summary. +

+ +
+
+
+ ); + }, + [handleExploreTemplatesClick, templateId, templateTitle], + ); + const contextMenu = useMemo(() => { + const items: MenuItemDef[] = [ + { + id: `copy-enhanced-${enhancedNoteId}`, + text: "Copy", + action: () => { + void handleCopy(); + }, + disabled: noteMarkdown.length === 0, + }, + { + id: `regenerate-enhanced-${enhancedNoteId}`, + text: "Regenerate", + action: handleRegenerate, + disabled: isGenerating, + }, + ]; + + if (canRemove) { + items.push({ separator: true }); + items.push({ + id: `remove-enhanced-${enhancedNoteId}`, + text: "Remove", + action: () => { + onRemove?.(); + }, + disabled: isGenerating || !onRemove, + }); + } + + return items; + }, [ + canRemove, + enhancedNoteId, + handleCopy, + handleRegenerate, + isGenerating, + noteMarkdown.length, + onRemove, + ]); + const showContextMenu = useNativeContextMenu(contextMenu); + if (isGenerating) { const step = currentStep as TaskStepInfo<"enhance"> | undefined; @@ -305,11 +577,12 @@ function HeaderTabEnhanced({ onCancel(); }; - return ( + return wrapWithTemplateTooltip(
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -317,7 +590,7 @@ function HeaderTabEnhanced({ } }} className={cn([ - "group/tab relative my-2 shrink-0 cursor-pointer border-b-2 px-1 py-0.5 text-xs font-medium transition-all duration-200", + "group/tab relative my-2 shrink-0 cursor-pointer border-b-2 px-1 py-0.5 text-xs font-medium transition-all duration-200 select-none", isActive ? ["text-neutral-900", "border-neutral-900"] : [ @@ -328,7 +601,7 @@ function HeaderTabEnhanced({ ])} > - + -
+ , ); } @@ -387,11 +660,43 @@ function HeaderTabEnhanced({
); - return ( - - + return wrapWithTemplateTooltip( + + {isActive && regenerateIcon} - + , + ); +} + +function useOpenTemplatesTab() { + const openNew = useTabs((state) => state.openNew); + const selectTab = useTabs((state) => state.select); + const updateTemplatesTabState = useTabs( + (state) => state.updateTemplatesTabState, + ); + + return useCallback( + (state: Extract["state"]) => { + const existingTemplatesTab = useTabs + .getState() + .tabs.find( + (tab): tab is Extract => + tab.type === "templates", + ); + + if (!existingTemplatesTab) { + openNew({ type: "templates", state }); + return; + } + + updateTemplatesTabState(existingTemplatesTab, state); + selectTab(existingTemplatesTab); + }, + [openNew, selectTab, updateTemplatesTabState], ); } @@ -427,11 +732,7 @@ function CreateOtherFormatButton({ data: suggestedTemplates = [], isLoading: isSuggestedTemplatesLoading, } = useWebResources("templates"); - const openNew = useTabs((state) => state.openNew); - const selectTab = useTabs((state) => state.select); - const updateTemplatesTabState = useTabs( - (state) => state.updateTemplatesTabState, - ); + const openTemplatesTab = useOpenTemplatesTab(); const setRow = main.UI.useSetRowCallback( "templates", (p: { @@ -465,26 +766,6 @@ function CreateOtherFormatButton({ main.STORE_ID, ); - const openTemplatesTab = useCallback( - (state: Extract["state"]) => { - const existingTemplatesTab = useTabs - .getState() - .tabs.find( - (tab): tab is Extract => - tab.type === "templates", - ); - - if (!existingTemplatesTab) { - openNew({ type: "templates", state }); - return; - } - - updateTemplatesTabState(existingTemplatesTab, state); - selectTab(existingTemplatesTab); - }, - [openNew, selectTab, updateTemplatesTabState], - ); - const handleUseTemplate = useCallback( (templateId: string) => { setOpen(false); @@ -877,7 +1158,7 @@ function CreateOtherFormatButton({ ); -} +}); + +NoteTab.displayName = "NoteTab"; From 75fb8ced15571416c89c7b4a58ff591957e009fb Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 31 Mar 2026 21:25:38 -0700 Subject: [PATCH 2/2] fix: restore template note tab titles Use the persisted enhanced note title for template-backed tabs so summaries remain distinguishable in the note header. --- .../session/components/note-input/header.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/session/components/note-input/header.tsx b/apps/desktop/src/session/components/note-input/header.tsx index 4a46c8e777..8935da7ca6 100644 --- a/apps/desktop/src/session/components/note-input/header.tsx +++ b/apps/desktop/src/session/components/note-input/header.tsx @@ -433,6 +433,12 @@ function HeaderTabEnhanced({ "content", main.STORE_ID, ) as string | undefined; + const rawTitle = main.UI.useCell( + "enhanced_notes", + enhancedNoteId, + "title", + main.STORE_ID, + ); const templateId = main.UI.useCell( "enhanced_notes", enhancedNoteId, @@ -451,15 +457,18 @@ function HeaderTabEnhanced({ : null; const openTemplatesTab = useOpenTemplatesTab(); const summaryTitle = "Summary"; - const tabLabel = templateTitle ?? summaryTitle; + const tabTitle = + typeof rawTitle === "string" && rawTitle.trim() + ? rawTitle.trim() + : summaryTitle; const noteMarkdown = useMemo(() => getStoredNoteMarkdown(content), [content]); const handleCopy = useCallback(() => { return copyTextToClipboard(noteMarkdown, { - success: `${tabLabel} copied to clipboard`, - error: `Failed to copy ${tabLabel}`, + success: `${tabTitle} copied to clipboard`, + error: `Failed to copy ${tabTitle}`, }); - }, [noteMarkdown, tabLabel]); + }, [noteMarkdown, tabTitle]); const handleRegenerate = useCallback(() => { void onRegenerate(null); }, [onRegenerate]); @@ -601,7 +610,7 @@ function HeaderTabEnhanced({ ])} > - +