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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/analyze/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1753,7 +1753,22 @@ export default function AnalyzePage() {
});
}, [promptSignInForNotes, user]);

const handleSaveEditingNote = useCallback(async ({ noteText, selectedText }: { noteText: string; selectedText: string }) => {
const handleAddNote = useCallback(() => {
if (!user) {
promptSignInForNotes();
return;
}

rightColumnTabsRef.current?.switchToNotes();

setEditingNote({
text: "",
metadata: null,
source: "custom",
});
}, [user, promptSignInForNotes]);

const handleSaveEditingNote = useCallback(async ({ noteText, selectedText, metadata }: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => {
if (!editingNote || !videoId) return;

// Use source from editing note or determine from metadata
Expand All @@ -1771,8 +1786,12 @@ export default function AnalyzePage() {
? {
...(editingNote.metadata ?? {}),
selectedText: normalizedSelected,
...(metadata ?? {})
}
: editingNote.metadata ?? undefined;
: {
...(editingNote.metadata ?? {}),
...(metadata ?? {})
};
Copy link

Choose a reason for hiding this comment

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

Metadata spread order overwrites edited selected text

High Severity

When merging metadata in handleSaveEditingNote, the ...(metadata ?? {}) spread is placed after selectedText: normalizedSelected. Since metadata comes from NoteEditor and contains a copy of editingNote.metadata (which already has selectedText from selection-actions.tsx line 165), the original selectedText overwrites any user edits. If a user modifies the quote text in the editor before saving, their changes are silently lost.

Fix in Cursor Fix in Web


await handleSaveNote({
text: noteText,
Expand Down Expand Up @@ -1999,6 +2018,7 @@ export default function AnalyzePage() {
editingNote={editingNote}
onSaveEditingNote={handleSaveEditingNote}
onCancelEditing={handleCancelEditing}
onAddNote={handleAddNote}
isAuthenticated={!!user}
onRequestSignIn={handleAuthRequired}
selectedLanguage={selectedLanguage}
Expand Down
193 changes: 122 additions & 71 deletions components/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,40 @@ import { Textarea } from "@/components/ui/textarea";
import { NoteMetadata } from "@/lib/types";
import { enhanceNoteQuote } from "@/lib/notes-client";
import { toast } from "sonner";
import { Send, Sparkles, Loader2, RotateCcw, Check } from "lucide-react";
import { Send, Sparkles, Loader2, RotateCcw, Check, Clock } from "lucide-react";
import { formatDuration } from "@/lib/utils";

interface NoteEditorProps {
selectedText: string;
metadata?: NoteMetadata | null;
onSave: (payload: { noteText: string; selectedText: string }) => void;
currentTime?: number;
onSave: (payload: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => void;
onCancel: () => void;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

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

The onCancel prop is defined in the interface but never used in the component. This means users cannot cancel note editing, which is a critical UX issue. The component should include a Cancel button that calls onCancel.

Copilot uses AI. Check for mistakes.
}

export function NoteEditor({ selectedText, onSave }: NoteEditorProps) {
export function NoteEditor({ selectedText, metadata, currentTime, onSave }: NoteEditorProps) {
const [originalQuote, setOriginalQuote] = useState(selectedText.trim());
const [quoteText, setQuoteText] = useState(selectedText.trim());
const [additionalText, setAdditionalText] = useState("");
const [isEnhancing, setIsEnhancing] = useState(false);
const [hasEnhanced, setHasEnhanced] = useState(false);
const [capturedTimestamp, setCapturedTimestamp] = useState<number | null>(null);

useEffect(() => {
const trimmed = selectedText.trim();
setOriginalQuote(trimmed);
setQuoteText(trimmed);
setHasEnhanced(false);
setAdditionalText("");
setCapturedTimestamp(null);
}, [selectedText]);

const handleCaptureTimestamp = useCallback(() => {
if (currentTime !== undefined) {
setCapturedTimestamp(currentTime);
}
}, [currentTime]);

const handleSave = useCallback(() => {
const baseQuote = (quoteText || originalQuote).trim();
const noteParts: string[] = [];
Expand All @@ -47,11 +57,26 @@ export function NoteEditor({ selectedText, onSave }: NoteEditorProps) {
return;
}

// Merge metadata
let finalMetadata: NoteMetadata | undefined = metadata ? { ...metadata } : undefined;

if (capturedTimestamp !== null) {
finalMetadata = {
...(finalMetadata || {}),
transcript: {
...(finalMetadata?.transcript || {}),
start: capturedTimestamp,
},
timestampLabel: formatDuration(capturedTimestamp),
};
}
Comment on lines +63 to +72
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

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

When capturing a timestamp, the code overwrites any existing transcript metadata except for the 'start' field. If the original metadata has 'end', 'segmentIndex', or 'topicId' fields, they will be preserved via the spread. However, capturing a new timestamp on a note that already has transcript metadata may create inconsistent state (e.g., a note with start from timestamp capture but end/segmentIndex from the original selection). Consider whether to preserve or clear other transcript fields when capturing a new timestamp.

Copilot uses AI. Check for mistakes.

onSave({
noteText: noteParts.join("\n\n"),
selectedText: baseQuote || originalQuote,
metadata: finalMetadata
});
}, [additionalText, onSave, originalQuote, quoteText]);
}, [additionalText, onSave, originalQuote, quoteText, metadata, capturedTimestamp]);

const handleEnhance = useCallback(async () => {
if (isEnhancing || !quoteText.trim()) {
Expand Down Expand Up @@ -85,86 +110,112 @@ export function NoteEditor({ selectedText, onSave }: NoteEditorProps) {
}
};

const hasQuote = quoteText.length > 0;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

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

The variable name 'hasQuote' is confusing when used for custom notes without selected text. Consider renaming to 'hasSelectedText' or 'hasSnippet' to better reflect its purpose across both use cases.

Copilot uses AI. Check for mistakes.
const enhancementDisabled = isEnhancing || !quoteText.trim();

return (
<div className="relative rounded-xl bg-neutral-100 border border-[#ebecee] p-4 animate-in fade-in duration-200 w-full max-w-full">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.17em] text-muted-foreground/80 mb-1">
<span>Selected Snippet</span>
{hasEnhanced && !isEnhancing && (
<span className="flex items-center gap-1 text-emerald-600 font-medium normal-case tracking-[0.05em]">
<Check className="w-3 h-3" />
Cleaned
</span>
)}
</div>

<div className="border-l-2 border-primary/40 bg-white/60 pl-3 pr-3 py-2 mb-3">
<Textarea
value={quoteText}
onChange={(e) => setQuoteText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Edit the snippet before saving"
className="resize-none border-none bg-transparent px-1 py-0 text-sm text-foreground/90 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-none whitespace-pre-wrap break-words min-h-[72px]"
aria-label="Selected snippet editor"
/>
</div>

<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<Button
type="button"
variant="pill"
size="sm"
onClick={handleEnhance}
disabled={enhancementDisabled}
className="h-7 rounded-full px-3 text-xs"
>
{isEnhancing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Enhancing…
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5" />
Enhance with AI
</>
)}
</Button>
{hasEnhanced ? (
<button
type="button"
onClick={handleResetQuote}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="w-3 h-3" />
Reset to original
</button>
) : (
<span className="text-[11px] text-muted-foreground">
Removes filler words & typos
</span>
)}
</div>
{hasQuote && (
<>
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.17em] text-muted-foreground/80 mb-1">
<span>Selected Snippet</span>
{hasEnhanced && !isEnhancing && (
<span className="flex items-center gap-1 text-emerald-600 font-medium normal-case tracking-[0.05em]">
<Check className="w-3 h-3" />
Cleaned
</span>
)}
</div>

<div className="border-l-2 border-primary/40 bg-white/60 pl-3 pr-3 py-2 mb-3">
<Textarea
value={quoteText}
onChange={(e) => setQuoteText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Edit the snippet before saving"
className="resize-none border-none bg-transparent px-1 py-0 text-sm text-foreground/90 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-none whitespace-pre-wrap break-words min-h-[72px]"
aria-label="Selected snippet editor"
/>
</div>

<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhance}
disabled={enhancementDisabled}
className="h-7 rounded-full px-3 text-xs border-dashed border-slate-300 bg-white/50 text-slate-600 hover:bg-white hover:text-slate-900"
>
{isEnhancing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" />
Enhancing…
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" />
Enhance with AI
</>
)}
</Button>
{hasEnhanced ? (
<button
type="button"
onClick={handleResetQuote}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="w-3 h-3" />
Reset to original
</button>
) : (
<span className="text-[11px] text-muted-foreground">
Removes filler words & typos
</span>
)}
</div>
</>
)}

{hasQuote && (
<div className="text-[11px] uppercase tracking-[0.17em] text-muted-foreground/80 mb-1 mt-4">
Your Note
</div>
)}

<Textarea
value={additionalText}
onChange={(e) => setAdditionalText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add context or your own takeaway (optional)"
placeholder={hasQuote ? "Add context or your own takeaway (optional)" : "Write a note..."}
className="resize-none text-xs bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 pr-12 min-h-[90px] px-2 py-2 max-w-full"
rows={4}
autoFocus
autoFocus={!hasQuote}
/>

<Button
type="button"
onClick={handleSave}
size="icon"
className="absolute right-3 bottom-3 rounded-full h-8 w-8"
>
<Send className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center justify-between mt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCaptureTimestamp}
className="text-xs text-muted-foreground hover:text-primary gap-1 px-2 h-7"
>
<Clock className="w-3.5 h-3.5" />
{capturedTimestamp !== null
? `Timestamp: ${formatDuration(capturedTimestamp)}`
: (metadata?.timestampLabel ? `Timestamp: ${metadata.timestampLabel}` : "Capture Timestamp")}
</Button>

<Button
type="button"
onClick={handleSave}
size="icon"
className="rounded-full h-8 w-8 ml-auto"
>
<Send className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
}
Loading
Loading