From 3b26c18c3f19830b07d73f7fa7894f2a128bbc06 Mon Sep 17 00:00:00 2001
From: Mike Mulligan <130580+thebeline@users.noreply.github.com>
Date: Fri, 23 Oct 2026 11:45:30 -0400
Subject: [PATCH 1/6] feat(notes): highlight a selection note's words, found
again after a reprocess
A selection note keeps the span its words were selected at and the words
as the user saw them. The processed view now finds those words in the
transcript as it reads now and marks them, tinted and underlined: solid
where found exactly, dotted where found with changes (the original words
in the title). The underline keeps a mark visible on a bubble of the
tint's own colour. When they can't
be found near the note's time nothing is marked, and the note line strikes
the quote through, says the words changed, and quotes what the span reads
now. A click on the tinted words, or the new pencil on a saved note's line,
opens the note popover to edit the note or delete it, each write against
the revision shown; a 409 shows the note as it is now and keeps the text
typed, so a second Save replaces it knowingly.
Decision record (anchoring rule)
Context: a reprocess deletes every utterance and finalize supersedes the
live ones, so nothing in the transcript keeps a stable id or offset. The
audio clock is what survives: the note's start_ms/end_ms.
Rule: resolved at read time, never written back. Look only among words
timed within 2 s of the stored span; compare case-folded with punctuation
stripped; take the run with the fewest word edits from the quote
(approximate substring matching), ties to the run nearest the span's
middle. All words equal is exact, 1 - edits/words >= 0.5 is fuzzy, less is
unanchored. Placement still reads only the stored span, and prompts keep
the stored quote.
Alternatives: time span only (option C) would tint whatever words now sit
there, silently; prefix/suffix context columns (E) are a migration for a
gain only on repeated phrases, which the time tie-break already handles;
re-anchoring at finalize and writing back (F) would make the pipeline a
second writer of notes, bumping revisions under users' open edits.
Trade-offs: the margin and floor come from 7,500 selections made the way
the client makes them, on live and processed text of three benchmark
recordings, resolved on their final and reprocessed text. At 2 s and 0.5:
78% exact, 19% fuzzy, 4% unanchored; 95% marked on the right words, 1.5%
on wrong ones. A 5 s margin finds 0.2% more and marks 0.4% more wrongly.
A 0.6 floor (the design's 0.7 was a guess) refuses 1.6% more, 113 of
117 of them right, mostly two-word quotes with one word changed; 0.4 adds
0.1%. 92 of the 110 wrong marks are one-word quotes; the clear cases are a
word said twice within a few seconds, where the neighbouring copy is
marked.
---
docs/ARCHITECTURE.md | 1 +
docs/USAGE.md | 2 +
.../TranscriptSection.highlights.test.tsx | 319 ++++++++++++++++++
.../[id]/_components/TranscriptSection.tsx | 26 +-
frontend/src/components/TranscriptView.tsx | 48 ++-
.../notes/NoteOnSelectionPopover.tsx | 101 ++++--
.../src/components/notes/TimelineNoteLine.tsx | 34 +-
.../components/transcript/TranscriptGroup.tsx | 16 +-
.../components/transcript/TranscriptLine.tsx | 16 +-
.../transcript/TranscriptLineMenu.tsx | 2 +-
.../transcript/TranscriptLineText.test.tsx | 49 +++
.../transcript/TranscriptLineText.tsx | 52 ++-
.../transcript/TranscriptNoteEditor.tsx | 100 ++++++
.../transcript/TranscriptSelectionToolbar.tsx | 2 +-
.../transcript/_hooks/useNoteHighlights.ts | 62 ++++
.../components/transcript/noteHighlights.ts | 74 ++++
.../components/transcript/textDecorations.ts | 14 +-
frontend/src/lib/notes/noteOutbox.ts | 2 +
.../src/lib/notes/resolveSelection.test.ts | 285 ++++++++++++++++
frontend/src/lib/notes/resolveSelection.ts | 189 +++++++++++
frontend/src/lib/notes/useNoteEdits.ts | 125 +++++++
21 files changed, 1466 insertions(+), 53 deletions(-)
create mode 100644 frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
create mode 100644 frontend/src/components/transcript/TranscriptNoteEditor.tsx
create mode 100644 frontend/src/components/transcript/_hooks/useNoteHighlights.ts
create mode 100644 frontend/src/components/transcript/noteHighlights.ts
create mode 100644 frontend/src/lib/notes/resolveSelection.test.ts
create mode 100644 frontend/src/lib/notes/resolveSelection.ts
create mode 100644 frontend/src/lib/notes/useNoteEdits.ts
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 290d2881d..d2f7f6545 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -359,6 +359,7 @@ The user's notes are `timeline_notes` rows (`backend/models/timeline_note.py`) a
- **Reading them.** The detail read carries `timeline_notes_list_signature` (`count:hash` of each note's id and revision), not the notes, and the page fetches the list when it changes (`frontend/src/lib/notes/useTimelineNotes.ts`), and after its own writes when nothing polls. Every write that changes a listed note makes a new revision, so the signature changes whenever the list does.
- **Placement.** One rule, implemented twice: `place_timeline_notes` (`backend/utils/timeline_notes.py`) for prompts, and `placeTimelineNotes` (`frontend/src/lib/notes/placement.ts`) for the live and finished transcripts, both pinned by `backend/tests/fixtures/timeline_note_placement_cases.json`. A note at t (a selection at its last millisecond) follows the last non-blank line, in list order, starting at or before t. A note before the first line opens the transcript.
- **Views** place notes on their whole line list. A note after a hidden line shows after the visible line before it, and a note ends the run of grouped lines it follows. The views sort lines by start, while the prompts keep the stored order, which every writer emits by start: lines tied on start may sort either way without moving a note past a non-blank line (a property test in `placement.test.ts`). A list stored out of start order, which `PUT /transcripts/{id}/segments` accepts, would place a note differently in the views than in the prompts.
+- **Highlights.** A selection's `quoted_text` is a snapshot of the words the user saw, and its `start_ms`/`end_ms` are where they were. The processed view finds them again on every render, never writing anything back (`frontend/src/lib/notes/resolveSelection.ts`). The rule: look only among the words timed within 2 s of the span, compare them case-folded with punctuation stripped, and take the run with the fewest word edits from the quote, ties to the run nearest the span's middle. All words equal is *exact*; a score `1 − edits/quote words` of at least 0.5 is *fuzzy*; anything less is *unanchored*, and nothing is marked. A reprocess, a line edit or Find & Replace therefore never moves a note or its placement, which reads only the stored span, and the prompts keep the stored quote. Utterance ids and character offsets are never stored on a note, since a reprocess replaces both.
- **Prompts.** An anchored note is its own line after the line it follows, folded so it cannot pass for transcript. Head and tail notes go in `# User Notes Context`, quoted with `> `; chat puts them first and last. Speaker inference reads every note as a timestamped list. Generated notes end with `## User Notes`, which speaker renames never rewrite.
- **Meeting Edge** refreshes at once, ahead of its throttle, when `timeline_notes_signature` changes. It hashes each note's id and content (kind, text, anchors, quote, author, creation time), not its revision as the list signature does, so an edit that changes none of these refreshes nothing.
- **Revisions.** An edit names its base revision, and a delete may (`?expected_revision=`). A stale one is a 409 `revision_conflict` with the current note.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 227c8d8dd..c3d2f838b 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -129,6 +129,8 @@ In the transcript:
- Notes you placed during the meeting ([Live Notes](#live-notes)) show on their own line after the line they follow. When a line has two or more saved notes, they fold into a count, such as **2 notes**, which shows them when you click or tap it; a single note shows as it is. A note you write on this page stays shown once it saves, and one not saved yet always shows.
- The **⋯** button beside a speaker's name opens the line's menu: **Note on this line**, **Edit text**, **Change speaker** and **Play from here**, which plays on rather than stopping at the line's end. It shows when you point at the line or tab to it, and always on a touch screen, where pressing and holding the speaker's name also opens it. In the menu, the arrow keys move and Escape closes it. A note on a line quotes the line's words; Enter saves it and Shift+Enter starts a new line. One that can't be saved waits on its line with **Retry** and **Discard**.
- Select some text, in one line or across several, for a bar with **Add note** and **Copy**. A note on selected text quotes those words, and its time runs from the first word you touched to the last, estimated from the line's start and end. Escape or a click elsewhere puts the bar away. A note you have started typing stays open, whatever you click or select, until you save or cancel it.
+- The words a note is on are tinted and underlined in the transcript. After processing or a reprocess the words may read a little differently: Nojoin looks for them again near the note's time, and words found with changes are underlined with dots instead, the original words shown when you point at them. When they can no longer be found, nothing is marked, and the note shows its quote struck through, says the words changed, and quotes what the transcript says at that time now.
+- Click tinted words, or the pencil beside a saved note, to edit the note or delete it. If the note was changed on another device meanwhile, Nojoin shows its new text and keeps yours in the box: save again to replace it.
**Export** saves the notes, analytics and transcript as one PDF, Word or text file, or the audio as MP3 ([Exporting A Meeting](#exporting-a-meeting)).
diff --git a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
new file mode 100644
index 000000000..69dc4be75
--- /dev/null
+++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
@@ -0,0 +1,319 @@
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { forgetNoteOutboxes, noteOutboxFor } from "@/lib/notes/noteOutboxes";
+import { interpolatedTokens } from "@/lib/notes/selectionRange";
+import {
+ RecordingStatus,
+ type Recording,
+ type TimelineNote,
+ type TranscriptSegment,
+} from "@/types";
+
+import TranscriptSection from "./TranscriptSection";
+
+const createTimelineNote = vi.fn();
+const updateTimelineNote = vi.fn();
+const deleteTimelineNote = vi.fn();
+
+vi.mock("@/lib/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createTimelineNote: (...args: unknown[]) => createTimelineNote(...args),
+ updateTimelineNote: (...args: unknown[]) => updateTimelineNote(...args),
+ deleteTimelineNote: (...args: unknown[]) => deleteTimelineNote(...args),
+}));
+vi.mock("@/lib/notificationStore", () => ({
+ useNotificationStore: () => ({ addNotification: vi.fn() }),
+}));
+
+const originalScrollIntoView = Element.prototype.scrollIntoView;
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+});
+afterAll(() => {
+ Element.prototype.scrollIntoView = originalScrollIntoView;
+});
+
+const VENUE: TranscriptSegment = {
+ id: "u1",
+ speaker: "SPEAKER_1",
+ start: 65,
+ end: 68.6,
+ text: "We need forty thousand for the venue",
+};
+const SEGMENTS: TranscriptSegment[] = [
+ { id: "u0", speaker: "SPEAKER_0", start: 0, end: 2, text: "Good morning" },
+ VENUE,
+];
+
+/** The span a selection of `words` in the venue line had (B3c), with `quote` as written. */
+const spanOf = (words: string) => {
+ const from = VENUE.text.indexOf(words);
+ const tokens = interpolatedTokens(VENUE.text, VENUE.start * 1000, VENUE.end * 1000).filter(
+ (token) => token.from < from + words.length && token.to > from,
+ );
+ return { start_ms: tokens[0].startMs, end_ms: tokens[tokens.length - 1].endMs };
+};
+
+const selectionNote = (id: string, words: string, quote: string, body: string): TimelineNote => ({
+ id,
+ recording_id: "rec-1",
+ kind: "selection",
+ body,
+ ...spanOf(words),
+ quoted_text: quote,
+ revision: 3,
+ created_at: "2026-10-23T10:00:00",
+ updated_at: "2026-10-23T10:00:00",
+});
+
+/** Written on the words as they read now. */
+const EXACT = selectionNote("n-exact", "forty thousand", "forty thousand", "Check the figure");
+/** Written on the live text, which heard "fourteen"; the final pass heard "forty". */
+const FUZZY = selectionNote("n-fuzzy", "forty thousand for the venue", "fourteen thousand for the venue", "Is that per day?");
+/** Written on words the final pass no longer has anywhere near. */
+const GONE = selectionNote("n-gone", "forty thousand", "a budget of twelve", "Ask finance");
+
+const RECORDING = {
+ id: "rec-1",
+ name: "Weekly sync",
+ status: RecordingStatus.PROCESSED,
+ duration_seconds: 600,
+ speakers: [],
+} as unknown as Recording;
+
+const renderSection = (timelineNotes: TimelineNote[]) =>
+ render(
+ ,
+ );
+
+const venueText = () => screen.getByText((_, element) => element?.tagName === "P" && element.textContent === VENUE.text);
+const marks = () => Array.from(venueText().querySelectorAll("mark[data-note-id]"));
+const noteLine = (id: string) =>
+ screen.getAllByTestId("timeline-note").find((line) => line.dataset.noteId === id)!;
+
+const conflict = (note: TimelineNote) => ({
+ isAxiosError: true,
+ response: {
+ status: 409,
+ data: { detail: { code: "revision_conflict", message: "changed", note } },
+ },
+});
+
+beforeEach(() => {
+ forgetNoteOutboxes();
+ createTimelineNote.mockReset();
+ updateTimelineNote.mockReset();
+ deleteTimelineNote.mockReset();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("the words a note is on, in the processed transcript", () => {
+ it("are tinted and underlined where the quote is found as written, the line's text unchanged", () => {
+ renderSection([EXACT]);
+
+ expect(marks()).toHaveLength(1);
+ expect(marks()[0]).toHaveTextContent(/^forty thousand$/);
+ expect(marks()[0].dataset).toMatchObject({ noteId: "n-exact", noteAnchor: "exact" });
+ expect(marks()[0].className).toMatch(/decoration-solid/);
+ expect(marks()[0].className).not.toMatch(/decoration-dotted/);
+ expect(marks()[0]).toHaveAttribute("title", "Note: “Check the figure”");
+ expect(venueText().textContent).toBe(VENUE.text);
+ expect(within(noteLine("n-exact")).queryByTestId("timeline-note-words-changed")).toBeNull();
+ });
+
+ it("are underlined with dots where found with changes, the quote as written in the title", () => {
+ renderSection([FUZZY]);
+
+ expect(marks()).toHaveLength(1);
+ expect(marks()[0]).toHaveTextContent(/^forty thousand for the venue$/);
+ expect(marks()[0].dataset.noteAnchor).toBe("fuzzy");
+ expect(marks()[0].className).toMatch(/decoration-dotted/);
+ expect(marks()[0].title).toContain("Written on “fourteen thousand for the venue”");
+ expect(within(noteLine("n-fuzzy")).queryByTestId("timeline-note-words-changed")).toBeNull();
+ });
+
+ it("are marked once the note is saved, not before, so no mark opens an editor that can't save", async () => {
+ createTimelineNote.mockRejectedValueOnce({ isAxiosError: true, message: "Network Error" });
+ noteOutboxFor("rec-1").submit({
+ kind: "selection",
+ body: "Check the figure",
+ anchorMs: null,
+ selection: { startMs: EXACT.start_ms!, endMs: EXACT.end_ms!, quotedText: "forty thousand" },
+ });
+ renderSection([]);
+
+ const line = screen.getByTestId("timeline-note");
+ await waitFor(() => expect(line).toHaveAttribute("data-note-status", "failed"));
+ expect(marks()).toHaveLength(0);
+ expect(within(line).queryByRole("button", { name: /^Edit note/ })).toBeNull();
+
+ createTimelineNote.mockImplementationOnce(async (_recording: string, payload: { id: string }) => ({
+ ...EXACT,
+ id: payload.id,
+ revision: 1,
+ }));
+ fireEvent.click(within(line).getByRole("button", { name: /^Retry note/ }));
+ await waitFor(() => expect(marks()).toHaveLength(1));
+ expect(marks()[0]).toHaveTextContent(/^forty thousand$/);
+ expect(
+ within(screen.getByTestId("timeline-note")).getByRole("button", { name: /^Edit note/ }),
+ ).toBeInTheDocument();
+ });
+
+ it("are not marked when gone; the note line strikes the quote and says what is there now", () => {
+ renderSection([GONE]);
+
+ expect(marks()).toHaveLength(0);
+ const line = noteLine("n-gone");
+ expect(within(line).getByTestId("timeline-note-quote").className).toMatch(/line-through/);
+ expect(within(line).getByTestId("timeline-note-words-changed")).toHaveTextContent(
+ "Words changed since this note. Here it now reads “forty thousand”.",
+ );
+ });
+});
+
+describe("editing a note in the processed transcript", () => {
+ const openFromLine = (id: string, body: string) => {
+ fireEvent.click(within(noteLine(id)).getByRole("button", { name: `Edit note at 01:05, “${body}”` }));
+ return screen.getByRole("dialog", { name: "Edit the note at 01:05" });
+ };
+
+ it("saves new text against the note's revision, shows it, and gives focus back", async () => {
+ updateTimelineNote.mockImplementation(async (_recording, _id, update) => ({
+ ...EXACT,
+ body: update.body,
+ revision: 4,
+ }));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ const box = within(editor).getByRole("textbox", { name: "Note" });
+ expect(box).toHaveValue("Check the figure");
+ fireEvent.change(box, { target: { value: " Check it with finance " } });
+ fireEvent.click(within(editor).getByRole("button", { name: "Save note" }));
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(updateTimelineNote).toHaveBeenCalledWith("rec-1", "n-exact", {
+ expected_revision: 3,
+ body: "Check it with finance",
+ });
+ expect(noteLine("n-exact")).toHaveTextContent("Check it with finance");
+ expect(document.activeElement).toBe(
+ within(noteLine("n-exact")).getByRole("button", { name: /^Edit note at 01:05/ }),
+ );
+ // Opened again, it edits the revision the save made.
+ fireEvent.change(within(openFromLine("n-exact", "Check it with finance")).getByRole("textbox"), {
+ target: { value: "Again" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save note" }));
+ await waitFor(() => expect(updateTimelineNote).toHaveBeenCalledTimes(2));
+ expect(updateTimelineNote.mock.calls[1][2]).toEqual({ expected_revision: 4, body: "Again" });
+ });
+
+ it("opens from a click on the marked words, but not from a drag that selected text", () => {
+ renderSection([EXACT]);
+
+ const selection = { isCollapsed: false } as Selection;
+ const getSelection = vi.spyOn(window, "getSelection").mockReturnValue(selection);
+ fireEvent.click(marks()[0]);
+ expect(screen.queryByRole("dialog")).toBeNull();
+
+ getSelection.mockReturnValue({ isCollapsed: true } as Selection);
+ fireEvent.click(marks()[0]);
+ expect(
+ within(screen.getByRole("dialog", { name: "Edit the note at 01:05" })).getByRole("textbox"),
+ ).toHaveValue("Check the figure");
+ });
+
+ it("on a change made elsewhere, shows the note as it is now and keeps the text typed", async () => {
+ updateTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Changed on my phone", revision: 5 }));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } });
+ fireEvent.click(within(editor).getByRole("button", { name: "Save note" }));
+
+ await within(editor).findByText(
+ "This note was changed elsewhere. It now reads “Changed on my phone”. Save again to keep yours.",
+ );
+ expect(within(editor).getByRole("textbox")).toHaveValue("Mine");
+ expect(noteLine("n-exact")).toHaveTextContent("Changed on my phone");
+
+ updateTimelineNote.mockResolvedValueOnce({ ...EXACT, body: "Mine", revision: 6 });
+ fireEvent.click(within(editor).getByRole("button", { name: "Save note" }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(updateTimelineNote.mock.calls[1][2]).toEqual({ expected_revision: 5, body: "Mine" });
+ expect(noteLine("n-exact")).toHaveTextContent("Mine");
+ });
+
+ it("counts a save sent again after a lost answer as saved, when the note already reads so", async () => {
+ updateTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Mine", revision: 4 }));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } });
+ fireEvent.click(within(editor).getByRole("button", { name: "Save note" }));
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(noteLine("n-exact")).toHaveTextContent("Mine");
+ });
+
+ it("deletes on a second press, against its revision: its line and marks go, focus to the transcript", async () => {
+ deleteTimelineNote.mockResolvedValue(undefined);
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.click(within(editor).getByRole("button", { name: "Delete" }));
+ expect(deleteTimelineNote).not.toHaveBeenCalled();
+ fireEvent.click(within(editor).getByRole("button", { name: "Confirm delete" }));
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(deleteTimelineNote).toHaveBeenCalledWith("rec-1", "n-exact", { expectedRevision: 3 });
+ expect(screen.queryByTestId("timeline-note")).toBeNull();
+ expect(marks()).toHaveLength(0);
+ expect(document.activeElement).toHaveAttribute("id", "panel-transcript");
+ });
+
+ it("keeps a note changed elsewhere when deleting it, and says what it reads now", async () => {
+ deleteTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Changed on my phone", revision: 5 }));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.click(within(editor).getByRole("button", { name: "Delete" }));
+ fireEvent.click(within(editor).getByRole("button", { name: "Confirm delete" }));
+
+ await within(editor).findByText(
+ "This note was changed elsewhere. It now reads “Changed on my phone”. Delete again to remove it anyway.",
+ );
+ expect(noteLine("n-exact")).toHaveTextContent("Changed on my phone");
+ expect(marks()).toHaveLength(1);
+ });
+});
diff --git a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx
index 631ed2edb..acde0f120 100644
--- a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx
+++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx
@@ -6,6 +6,7 @@ import { AlertCircle } from "lucide-react";
import TranscriptView from "@/components/TranscriptView";
import RecordingUnsavedNotes from "@/components/notes/RecordingUnsavedNotes";
import { inlineNotes, type OutboxDraft } from "@/lib/notes/noteOutbox";
+import { useNoteEdits, type EditableNote } from "@/lib/notes/useNoteEdits";
import { useNoteOutbox } from "@/lib/notes/useNoteOutbox";
import {
RecordingStatus,
@@ -96,14 +97,19 @@ export default function TranscriptSection({
saved: outboxSaved,
discarded: outboxDiscarded,
} = outboxState;
+ // Edits and deletes made here show at once: a finished recording's list
+ // is not fetched again.
+ const { apply: applyEdits, edit: editNote, remove: removeNote } = useNoteEdits(recording.id);
const notes = useMemo(
() =>
- inlineNotes(
- timelineNotes,
- { entries: outboxEntries, saved: outboxSaved, discarded: outboxDiscarded },
- SELECTION_NOTES,
+ applyEdits(
+ inlineNotes(
+ timelineNotes,
+ { entries: outboxEntries, saved: outboxSaved, discarded: outboxDiscarded },
+ SELECTION_NOTES,
+ ),
),
- [timelineNotes, outboxEntries, outboxSaved, outboxDiscarded],
+ [applyEdits, timelineNotes, outboxEntries, outboxSaved, outboxDiscarded],
);
const sectionRef = useRef(null);
const durationSeconds = recording.duration_seconds;
@@ -120,6 +126,14 @@ export default function TranscriptSection({
},
[outbox, focusSection],
);
+ const deleteNote = useCallback(
+ async (note: EditableNote) => {
+ const refusal = await removeNote(note);
+ if (refusal === null) focusSection();
+ return refusal;
+ },
+ [removeNote, focusSection],
+ );
// A failed transcription is reported, never shown as an empty transcript,
// which would let a crashed ASR run pass for a silent meeting.
// Only while the recording is in ERROR: a retry or speaker-inference run
@@ -193,6 +207,8 @@ export default function TranscriptSection({
onAddNote={addNote}
onRetryNote={outbox.retry}
onDiscardNote={discardNote}
+ onEditNote={editNote}
+ onDeleteNote={deleteNote}
/>
) : (
diff --git a/frontend/src/components/TranscriptView.tsx b/frontend/src/components/TranscriptView.tsx
index 04a438b9a..41e5a533e 100644
--- a/frontend/src/components/TranscriptView.tsx
+++ b/frontend/src/components/TranscriptView.tsx
@@ -8,7 +8,7 @@ import {
TimelineNote,
TranscriptSpeakerAssignment,
} from "@/types";
-import { Fragment, useMemo, useState } from "react";
+import { Fragment, useMemo, useState, type MouseEvent } from "react";
import { Radio } from "lucide-react";
import { useTranscriptSearch } from "./transcript/_hooks/useTranscriptSearch";
import { useTranscriptScroll } from "./transcript/_hooks/useTranscriptScroll";
@@ -25,6 +25,11 @@ import { formatTime, isRecentlyUpdatedSegment } from "./transcript/transcriptDis
import TranscriptGroup, { type TranscriptLineShared } from "./transcript/TranscriptGroup";
import TranscriptSelectionNotes from "./transcript/TranscriptSelectionToolbar";
import TranscriptToolbar from "./transcript/TranscriptToolbar";
+import TranscriptNoteEditor, {
+ type EditingNote,
+ type NoteEditHandlers,
+} from "./transcript/TranscriptNoteEditor";
+import { isSavedNote, useNoteHighlights } from "./transcript/_hooks/useNoteHighlights";
import { bodyNotesAmongLines, placeTimelineNotes } from "@/lib/notes/placement";
import type { InlineNote, OutboxDraft } from "@/lib/notes/noteOutbox";
import CollapsedLineNotes from "./notes/CollapsedLineNotes";
@@ -79,6 +84,10 @@ interface TranscriptViewProps {
onRetryNote?: (key: string) => void;
/** Removes a failed note's line: the caller moves focus somewhere that stays. */
onDiscardNote?: (key: string) => void;
+ /** Saves a saved note's new text; without it, notes can't be edited here. */
+ onEditNote?: NoteEditHandlers["onEditNote"];
+ /** Deletes a saved note: the caller moves focus somewhere that stays. */
+ onDeleteNote?: NoteEditHandlers["onDeleteNote"];
}
// One empty list for every render, so the memoised lines it reaches hold.
@@ -87,9 +96,6 @@ const NO_PENDING_UPDATES: readonly string[] = [];
const NOTE_TIME_COLUMN =
"min-w-16 md:min-w-[60px] shrink-0 pt-1 text-right font-mono text-sm text-contrast-icon-muted";
-/** A note the server holds; one still on its way, or refused, is not. */
-const isSavedNote = (note: TimelineNote | InlineNote) => !("status" in note) || note.status === "saved";
-
/**
* The finished transcript: toolbar, then the lines in groups of overlapping
* speech, with the recording's notes among them. The parts live in
@@ -127,6 +133,8 @@ export default function TranscriptView({
onAddNote,
onRetryNote,
onDiscardNote,
+ onEditNote,
+ onDeleteNote,
}: TranscriptViewProps) {
// Find & Replace toolbar visibility (search match state lives in the hook)
const [showSearch, setShowSearch] = useState(false);
@@ -224,6 +232,21 @@ export default function TranscriptView({
),
[notePlacements, displaySegments],
);
+ // Each saved selection note's words, found in the lines as they read now.
+ const { noteDecorations, wordsChanged } = useNoteHighlights(segments, timelineNotes);
+ const [editingNote, setEditingNote] = useState(null);
+ const canEditNote = (note: TimelineNote | InlineNote) =>
+ onEditNote !== undefined && isSavedNote(note) && note.revision !== undefined;
+ // A click on a note's marked words edits the note, unless it ended a
+ // drag that selected text, which is for a new note or a copy.
+ const editNoteFromText = (event: MouseEvent) => {
+ const mark =
+ event.target instanceof Element ? event.target.closest("mark[data-note-id]") : null;
+ const noteId = mark?.dataset.noteId;
+ if (!mark || !noteId || !onEditNote) return;
+ if (window.getSelection()?.isCollapsed === false) return;
+ setEditingNote({ noteId, anchor: mark });
+ };
const renderNote = (note: TimelineNote | InlineNote) => {
const key = "key" in note ? note.key : note.id;
return (
@@ -232,6 +255,12 @@ export default function TranscriptView({
noteId={note.id}
body={note.body}
quote={note.kind === "selection" ? note.quoted_text : null}
+ wordsChanged={note.kind === "selection" ? wordsChanged(note.id) : null}
+ onEdit={
+ canEditNote(note)
+ ? (trigger) => setEditingNote({ noteId: note.id, anchor: trigger })
+ : undefined
+ }
timeLabel={formatTime(
(note.kind === "selection" ? (note.start_ms ?? 0) : (note.anchor_ms ?? 0)) / 1000,
)}
@@ -335,6 +364,7 @@ export default function TranscriptView({
data-testid="transcript-scroll-region"
className="space-y-4 px-2 md:px-4 pt-3 pb-clear-floating-3 overflow-y-auto flex-1 min-h-0"
onScroll={updateScrollAnchor}
+ onClick={editNoteFromText}
>
{trackGroups.length === 0 ? (
+ {onEditNote ? (
+ setEditingNote(null)}
+ />
+ ) : null}
);
}
diff --git a/frontend/src/components/notes/NoteOnSelectionPopover.tsx b/frontend/src/components/notes/NoteOnSelectionPopover.tsx
index cfb2af59b..aec2c10b6 100644
--- a/frontend/src/components/notes/NoteOnSelectionPopover.tsx
+++ b/frontend/src/components/notes/NoteOnSelectionPopover.tsx
@@ -4,17 +4,22 @@ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from
import { createPortal } from "react-dom";
import { stripNote } from "@/lib/notes/headNoteSync";
-import type { NoteSelection } from "@/lib/notes/noteOutbox";
import Button from "../ui/Button";
import { useAnchoredPanel } from "../ui/useAnchoredPanel";
import { NOTE_BUTTON_TOUCH } from "./noteButtons";
+/** Why a write was refused, or null once it is done or on its way. */
+type Refusal = string | null;
+
interface NoteOnSelectionPopoverProps {
- selection: NoteSelection;
+ /** The words the note is on; null for a note on a point in time. */
+ quote: string | null;
/** Names the popover: "Note on the line at 01:05". */
title: string;
+ /** The note's text when it opens: an edit's, or empty for a new note. */
+ initialText?: string;
/**
* What it sits against, which stays mounted while it is open: a line's ⋯
* button, or the place of a text selection. Focus goes back there when it
@@ -23,27 +28,36 @@ interface NoteOnSelectionPopoverProps {
anchor: HTMLElement;
/** Puts focus back when it closes, for an anchor that can't take focus. */
returnFocus?: () => void;
- /** Queues the note; returns why it was refused, or null once it is on its way. */
- onSubmit: (body: string) => string | null;
+ /**
+ * Queues or saves the note. Returns, or resolves to, why it was refused, and
+ * the popover stays open with the text as typed; null closes it.
+ */
+ onSubmit: (body: string) => Refusal | Promise;
+ /** Deletes the note, after a second press to confirm; null closes the popover. */
+ onDelete?: () => Promise;
onClose: () => void;
}
/**
- * A note on chosen transcript text, in the finished view: the quote, a box
- * and Save. Enter saves and Shift+Enter starts a new line, as in the live
- * note box; Escape and Cancel close it. A press outside closes it only while
- * the box is empty, so a stray tap never throws a note away.
+ * A note in the finished view: the quote it is on, a box and Save, and for a
+ * note being edited, Delete. Enter saves and Shift+Enter starts a new line, as
+ * in the live note box; Escape and Cancel close it. A press outside closes it
+ * only while the text is as it opened, so a stray tap never throws a note away.
*/
export default function NoteOnSelectionPopover({
- selection,
+ quote,
title,
+ initialText = "",
anchor,
returnFocus,
onSubmit,
+ onDelete,
onClose,
}: NoteOnSelectionPopoverProps) {
- const [text, setText] = useState("");
+ const [text, setText] = useState(initialText);
const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
const anchorRef = useMemo(() => ({ current: anchor }), [anchor]);
const { panelRef, panelStyle } = useAnchoredPanel(true, anchorRef);
const boxRef = useRef(null);
@@ -51,6 +65,7 @@ export default function NoteOnSelectionPopover({
const titleId = useId();
const errorId = useId();
const isBlank = stripNote(text) === "";
+ const isUnchanged = stripNote(text) === stripNote(initialText);
const close = () => {
onClose();
@@ -65,7 +80,7 @@ export default function NoteOnSelectionPopover({
}, [placed]);
useEffect(() => {
- if (!isBlank) return;
+ if (!isUnchanged) return;
const closeOnOutsidePress = (event: PointerEvent) => {
const target = event.target as Node;
if (panelRef.current?.contains(target) || anchor.contains(target)) return;
@@ -73,16 +88,42 @@ export default function NoteOnSelectionPopover({
};
document.addEventListener("pointerdown", closeOnOutsidePress, true);
return () => document.removeEventListener("pointerdown", closeOnOutsidePress, true);
- }, [isBlank, anchor, onClose, panelRef]);
+ }, [isUnchanged, anchor, onClose, panelRef]);
+
+ const finish = (refusal: Refusal) => {
+ if (refusal) setError(refusal);
+ else close();
+ };
+
+ /**
+ * Runs a write; closes once it is done or on its way, else says why and
+ * keeps the text. A queued note answers at once; a save waits for Nojoin.
+ */
+ const settle = (write: () => Refusal | Promise) => {
+ const outcome = write();
+ if (!(outcome instanceof Promise)) {
+ finish(outcome);
+ return;
+ }
+ setBusy(true);
+ void outcome.then((refusal) => {
+ setBusy(false);
+ finish(refusal);
+ });
+ };
const save = () => {
- if (isBlank) return;
- const refusal = onSubmit(text);
- if (refusal) {
- setError(refusal);
+ if (isBlank || busy) return;
+ settle(() => onSubmit(text));
+ };
+
+ const remove = () => {
+ if (!onDelete || busy) return;
+ if (!confirmingDelete) {
+ setConfirmingDelete(true);
return;
}
- close();
+ settle(onDelete);
};
const handleKeyDown = (event: KeyboardEvent) => {
@@ -109,7 +150,7 @@ export default function NoteOnSelectionPopover({
);
const edge = event.shiftKey ? stops[0] : stops[stops.length - 1];
if (document.activeElement !== edge) return;
- if (isBlank) {
+ if (isUnchanged) {
close();
return;
}
@@ -135,9 +176,11 @@ export default function NoteOnSelectionPopover({
{title}
-
- {selection.quotedText}
-
+ {quote ? (
+
+ {quote}
+
+ ) : null}
) : null}
-
+
+ {onDelete ? (
+
+ ) : null}
) : null}
+ {onEdit && status === "saved" ? (
+ }
+ onClick={(event) => onEdit(event.currentTarget)}
+ className={`shrink-0 -my-1 ${TOUCH_TARGET_SQUARE}`}
+ />
+ ) : null}
);
}
diff --git a/frontend/src/components/transcript/TranscriptGroup.tsx b/frontend/src/components/transcript/TranscriptGroup.tsx
index d51a38c15..558838dfa 100644
--- a/frontend/src/components/transcript/TranscriptGroup.tsx
+++ b/frontend/src/components/transcript/TranscriptGroup.tsx
@@ -71,6 +71,8 @@ interface TranscriptGroupProps {
/** Keys of this group's lines revised in the last 15 s, one per line. */
recentLineKeys: string;
searchDecorations: (segmentId: string) => readonly TextDecoration[];
+ /** Each line's marks of the words selection notes are on. */
+ noteDecorations: (segmentId: string) => readonly TextDecoration[];
/** The line whose speaker rename is open. */
editingSpeakerLineId: string | null;
editingTextId: string | null;
@@ -100,6 +102,7 @@ function TranscriptGroup({
activeLineKeys,
recentLineKeys,
searchDecorations,
+ noteDecorations,
editingSpeakerLineId,
editingTextId,
activePopover,
@@ -136,6 +139,7 @@ function TranscriptGroup({
}
isRecentlyUpdated={recentKeys.includes(segmentId)}
decorations={searchDecorations(segmentId)}
+ noteDecorations={noteDecorations(segmentId)}
isEditingSpeaker={isEditingSpeaker}
isEditingText={isEditingText}
popoverAnchor={activePopover?.segmentId === segmentId ? activePopover.anchor : null}
@@ -255,11 +259,11 @@ function TranscriptGroup({
}
/**
- * Every prop by identity, except three that are rebuilt more often than they
+ * Every prop by identity, except four that are rebuilt more often than they
* change: the group, which grouping makes anew whenever any line changes; the
* speaker order, which only matters for this group's speakers; and the search
- * decorations, a function that is new whenever any line's matches change.
- * Those compare by what this group would draw from them.
+ * and note decorations, functions that are new whenever any line's marks
+ * change. Those compare by what this group would draw from them.
*/
function sameGroupProps(previous: TranscriptGroupProps, next: TranscriptGroupProps): boolean {
const keys = Object.keys(next) as (keyof TranscriptGroupProps)[];
@@ -275,11 +279,9 @@ function sameGroupProps(previous: TranscriptGroupProps, next: TranscriptGroupPro
previous.speakerDisplayOrder.get(speaker) === next.speakerDisplayOrder.get(speaker),
);
case "searchDecorations":
+ case "noteDecorations":
return next.group.items.every(({ segmentId }) =>
- sameDecorations(
- previous.searchDecorations(segmentId),
- next.searchDecorations(segmentId),
- ),
+ sameDecorations(previous[key](segmentId), next[key](segmentId)),
);
default:
return Object.is(previous[key], next[key]);
diff --git a/frontend/src/components/transcript/TranscriptLine.tsx b/frontend/src/components/transcript/TranscriptLine.tsx
index f20fc010f..aafc2e5dd 100644
--- a/frontend/src/components/transcript/TranscriptLine.tsx
+++ b/frontend/src/components/transcript/TranscriptLine.tsx
@@ -1,6 +1,6 @@
"use client";
-import { memo } from "react";
+import { memo, useMemo } from "react";
import { getColorByKey } from "@/lib/constants";
import type { OutboxDraft } from "@/lib/notes/noteOutbox";
@@ -50,6 +50,8 @@ type TranscriptLineProps = Pick
+ noteDecorations.length === 0
+ ? decorations
+ : decorations.length === 0
+ ? noteDecorations
+ : [...decorations, ...noteDecorations],
+ [decorations, noteDecorations],
+ );
const isProvisional = segment.provisional === true;
const isSegmentReadOnly = readOnly || (isProvisional && !allowProvisionalEdits);
const speakerName = segmentSpeakerDisplayName(speakerMap, segment.speaker);
@@ -225,7 +237,7 @@ function TranscriptLine({
segmentId={segmentId}
startMs={segmentStartMs(segment)}
endMs={segmentEndMs(segment)}
- decorations={decorations}
+ decorations={textDecorations}
className="leading-relaxed whitespace-pre-wrap text-contrast-muted"
/>
)}
diff --git a/frontend/src/components/transcript/TranscriptLineMenu.tsx b/frontend/src/components/transcript/TranscriptLineMenu.tsx
index 21a69c1bd..19734bb95 100644
--- a/frontend/src/components/transcript/TranscriptLineMenu.tsx
+++ b/frontend/src/components/transcript/TranscriptLineMenu.tsx
@@ -94,7 +94,7 @@ export default function TranscriptLineMenu({
/>
{noteAnchor && selection && onAddNote ? (
onAddNote({ kind: "selection", body, anchorMs: null, selection })}
diff --git a/frontend/src/components/transcript/TranscriptLineText.test.tsx b/frontend/src/components/transcript/TranscriptLineText.test.tsx
index ac3680e68..6693449d0 100644
--- a/frontend/src/components/transcript/TranscriptLineText.test.tsx
+++ b/frontend/src/components/transcript/TranscriptLineText.test.tsx
@@ -116,6 +116,55 @@ describe("TranscriptLineText", () => {
expect(pieces(line)).toEqual(["a", "[]", "b"]);
});
+ it("marks a note's words under a search match, which draws over them", () => {
+ const note = (from: number, to: number, fuzzy = false): TextDecoration => ({
+ kind: "note",
+ from,
+ to,
+ noteId: fuzzy ? "n-2" : "n-1",
+ fuzzy,
+ title: "Note: “why”",
+ });
+ // In either order: a line hands its search matches first, then its notes.
+ for (const decorations of [
+ [search(6, 10), note(0, 14)],
+ [note(0, 14), search(6, 10)],
+ ]) {
+ const line = renderLineText("forty thousand for the venue", decorations);
+
+ expect(pieces(line)).toEqual(["[forty ]", "[thou]", "[sand]", " for the venue"]);
+ const [before, match, after] = Array.from(line.querySelectorAll("mark"));
+ expect(match.dataset.noteId).toBeUndefined();
+ expect(match.className).toMatch(/bg-status-warning-bg/);
+ for (const mark of [before, after]) {
+ expect(mark.dataset).toMatchObject({ noteId: "n-1", noteAnchor: "exact" });
+ expect(mark.title).toBe("Note: “why”");
+ }
+ expect(line.textContent).toBe("forty thousand for the venue");
+ cleanup();
+ }
+ // And where the match starts first and runs into the note's words.
+ const into = renderLineText("forty thousand for the venue", [search(0, 10), note(6, 14)]);
+ expect(pieces(into)).toEqual(["[forty thou]", "[sand]", " for the venue"]);
+ const [match, rest] = Array.from(into.querySelectorAll("mark"));
+ expect(match.dataset.noteId).toBeUndefined();
+ expect(rest.dataset.noteId).toBe("n-1");
+ cleanup();
+
+ // Where an exact note's words and a changed one's overlap, they draw as
+ // changed, whichever note starts first.
+ const anchors = (decorations: TextDecoration[]) =>
+ Array.from(renderLineText("forty thousand", decorations).querySelectorAll("mark")).map(
+ (mark) => [mark.textContent, mark.dataset.noteAnchor],
+ );
+ expect(anchors([note(0, 14), note(6, 14, true)])).toEqual([
+ ["forty ", "exact"],
+ ["thousand", "fuzzy"],
+ ]);
+ cleanup();
+ expect(anchors([note(0, 14, true), note(6, 14)])).toEqual([["forty thousand", "fuzzy"]]);
+ });
+
it("names each line and its span on the timeline alike in the live and finished views", () => {
const segments: TranscriptSegment[] = [
{ id: "utt-1", start: 1.25, end: 3.5, text: "First line.", speaker: "UNKNOWN" },
diff --git a/frontend/src/components/transcript/TranscriptLineText.tsx b/frontend/src/components/transcript/TranscriptLineText.tsx
index d60471393..7004e753d 100644
--- a/frontend/src/components/transcript/TranscriptLineText.tsx
+++ b/frontend/src/components/transcript/TranscriptLineText.tsx
@@ -2,7 +2,12 @@
import type { ReactNode } from "react";
-import { buildTextRuns, type TextDecoration, type TextRun } from "./textDecorations";
+import {
+ buildTextRuns,
+ type NoteDecoration,
+ type TextDecoration,
+ type TextRun,
+} from "./textDecorations";
interface TranscriptLineTextProps {
text: string;
@@ -17,19 +22,27 @@ interface TranscriptLineTextProps {
const NO_DECORATIONS: readonly TextDecoration[] = [];
-/** How a run draws: `null` for plain text. */
-type SearchLook = "match" | "current" | null;
+/**
+ * How a run draws: `null` for plain text. A search match draws over a note's
+ * words, since it is what the reader is looking for now; a note's words found
+ * with changes draw as such when any note on them was.
+ */
+type Look = "match" | "current" | "note" | "note-changed" | null;
-const lookOf = (decorations: readonly TextDecoration[]): SearchLook => {
- let look: SearchLook = null;
+const lookOf = (decorations: readonly TextDecoration[]): Look => {
+ let look: Look = null;
for (const decoration of decorations) {
switch (decoration.kind) {
case "search":
look = look === "current" || decoration.current ? "current" : "match";
break;
+ case "note":
+ if (look === "match" || look === "current") break;
+ look = look === "note-changed" || decoration.fuzzy ? "note-changed" : "note";
+ break;
default: {
// A new kind fails to compile here until it has a look.
- const unhandled: never = decoration.kind;
+ const unhandled: never = decoration;
return unhandled;
}
}
@@ -37,6 +50,15 @@ const lookOf = (decorations: readonly TextDecoration[]): SearchLook => {
return look;
};
+const SEARCH_CLASSES = {
+ match: "bg-status-warning-bg text-status-warning-fg",
+ current: "bg-action text-action-on",
+};
+
+/** The first note on a run, which a click on it edits. */
+const noteOf = (decorations: readonly TextDecoration[]) =>
+ decorations.find((decoration): decoration is NoteDecoration => decoration.kind === "note");
+
/**
* Joins neighbouring runs that draw alike and share a decoration, so matches
* that overlap read as one mark per look rather than a mark per character.
@@ -74,10 +96,26 @@ const renderRun = (text: string, run: TextRun, index: number): ReactNode => {
if (look === null) {
return slice;
}
+ if (look === "match" || look === "current") {
+ return (
+
+ {slice}
+
+ );
+ }
+ const note = noteOf(run.decorations);
+ // No padding: a note's words keep their place, so the line reads as it did.
+ // Underlined as well as tinted, since a bubble can be the tint's own colour
+ // (an orange speaker); dotted where the words have changed.
return (
{slice}
diff --git a/frontend/src/components/transcript/TranscriptNoteEditor.tsx b/frontend/src/components/transcript/TranscriptNoteEditor.tsx
new file mode 100644
index 000000000..e9074ac34
--- /dev/null
+++ b/frontend/src/components/transcript/TranscriptNoteEditor.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useRef } from "react";
+
+import type { InlineNote } from "@/lib/notes/noteOutbox";
+import type { EditableNote, NoteEditRefusal } from "@/lib/notes/useNoteEdits";
+import type { TimelineNote } from "@/types";
+
+import NoteOnSelectionPopover from "../notes/NoteOnSelectionPopover";
+
+import { isSavedNote } from "./_hooks/useNoteHighlights";
+import { formatTime } from "./transcriptDisplay";
+
+/** The note being edited, and what its editor sits against. */
+export interface EditingNote {
+ noteId: string;
+ /** The note line's Edit button, or the marked words that were clicked. */
+ anchor: HTMLElement;
+}
+
+export interface NoteEditHandlers {
+ onEditNote: (note: EditableNote, body: string) => Promise;
+ onDeleteNote?: (note: EditableNote) => Promise;
+}
+
+interface TranscriptNoteEditorProps extends NoteEditHandlers {
+ editing: EditingNote | null;
+ /** The notes as shown, so the editor always writes against the latest revision. */
+ notes: readonly (TimelineNote | InlineNote)[] | null | undefined;
+ onClose: () => void;
+}
+
+/**
+ * Where focus goes back to: the Edit button pressed, or for marked words,
+ * which take no focus, their line's ⋯ button.
+ */
+const focusAfter = (anchor: HTMLElement) => {
+ if (anchor.tagName !== "MARK") {
+ anchor.focus();
+ return;
+ }
+ anchor
+ .closest("[data-line-actions]")
+ ?.querySelector('[aria-haspopup="menu"]')
+ ?.focus();
+};
+
+function Editor({
+ note,
+ anchor,
+ onEditNote,
+ onDeleteNote,
+ onClose,
+}: NoteEditHandlers & { note: TimelineNote | InlineNote; anchor: HTMLElement; onClose: () => void }) {
+ // A deleted note's line is going: the caller has moved focus somewhere that stays.
+ const removed = useRef(false);
+ const startMs = note.kind === "selection" ? note.start_ms : note.anchor_ms;
+ return (
+ {
+ if (!removed.current) focusAfter(anchor);
+ }}
+ onSubmit={(body) => onEditNote(note, body)}
+ onDelete={
+ onDeleteNote
+ ? async () => {
+ const refusal = await onDeleteNote(note);
+ removed.current = refusal === null;
+ return refusal;
+ }
+ : undefined
+ }
+ onClose={onClose}
+ />
+ );
+}
+
+/**
+ * The editor of a saved note in the finished view, opened from its note
+ * line's Edit button or a click on its marked words: its text to change, and
+ * Delete. Writes go against the revision shown now, so a change made
+ * elsewhere meanwhile is refused rather than overwritten.
+ */
+export default function TranscriptNoteEditor({ editing, notes, onClose, ...handlers }: TranscriptNoteEditorProps) {
+ const note = editing ? notes?.find((candidate) => candidate.id === editing.noteId) : undefined;
+ if (!editing || !note || !isSavedNote(note)) return null;
+ return (
+
+ );
+}
diff --git a/frontend/src/components/transcript/TranscriptSelectionToolbar.tsx b/frontend/src/components/transcript/TranscriptSelectionToolbar.tsx
index b0fcef240..242e3acfc 100644
--- a/frontend/src/components/transcript/TranscriptSelectionToolbar.tsx
+++ b/frontend/src/components/transcript/TranscriptSelectionToolbar.tsx
@@ -195,7 +195,7 @@ function SelectionNoteWriter({
{anchor ? (
lineTrigger(root.current, writing.segmentId)?.focus()}
diff --git a/frontend/src/components/transcript/_hooks/useNoteHighlights.ts b/frontend/src/components/transcript/_hooks/useNoteHighlights.ts
new file mode 100644
index 000000000..45da0353b
--- /dev/null
+++ b/frontend/src/components/transcript/_hooks/useNoteHighlights.ts
@@ -0,0 +1,62 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+
+import type { InlineNote } from "@/lib/notes/noteOutbox";
+import { segmentEndMs, segmentStartMs } from "@/lib/notes/placement";
+import type { DrawnLine } from "@/lib/notes/selectionRange";
+import type { TimelineNote, TranscriptSegment } from "@/types";
+
+import { noteHighlights, textOfRanges } from "../noteHighlights";
+import type { TextDecoration } from "../textDecorations";
+import { indexSegments } from "../transcriptGroups";
+
+const NO_MARKS: readonly TextDecoration[] = [];
+
+/** A note the server holds; one still on its way, or refused, is not. */
+export const isSavedNote = (note: TimelineNote | InlineNote) =>
+ !("status" in note) || note.status === "saved";
+
+/**
+ * The saved selection notes' words, found in the lines as they read now: the
+ * marks for each line, and for a note whose words were not found, what its
+ * span reads now. Resolved over every line, hidden ones too, so hiding a
+ * line never makes a note's words look changed. Found again only when the
+ * lines or the notes change, never on a playback tick.
+ */
+export function useNoteHighlights(
+ segments: TranscriptSegment[],
+ notes: readonly (TimelineNote | InlineNote)[] | null | undefined,
+) {
+ const lines = useMemo(
+ () =>
+ indexSegments(segments).map(({ segment, segmentId }) => ({
+ segmentId,
+ text: segment.text,
+ startMs: segmentStartMs(segment),
+ endMs: segmentEndMs(segment),
+ })),
+ [segments],
+ );
+ const highlights = useMemo(
+ () => noteHighlights((notes ?? []).filter(isSavedNote), lines),
+ [notes, lines],
+ );
+
+ const noteDecorations = useCallback(
+ (segmentId: string): readonly TextDecoration[] => highlights.byLine.get(segmentId) ?? NO_MARKS,
+ [highlights],
+ );
+
+ /** For a note whose words aren't in the transcript now, what its span reads; else null. */
+ const wordsChanged = useCallback(
+ (noteId: string): { nowReads: string } | null => {
+ const resolution = highlights.byNote.get(noteId);
+ if (resolution?.state !== "unanchored") return null;
+ return { nowReads: textOfRanges(resolution.bestGuess, lines) };
+ },
+ [highlights, lines],
+ );
+
+ return { noteDecorations, wordsChanged };
+}
diff --git a/frontend/src/components/transcript/noteHighlights.ts b/frontend/src/components/transcript/noteHighlights.ts
new file mode 100644
index 000000000..812ae178d
--- /dev/null
+++ b/frontend/src/components/transcript/noteHighlights.ts
@@ -0,0 +1,74 @@
+import { noteSnippet } from "@/lib/notes/noteOutbox";
+import { resolveSelection, type Resolution } from "@/lib/notes/resolveSelection";
+import type { DrawnLine } from "@/lib/notes/selectionRange";
+
+import type { NoteDecoration } from "./textDecorations";
+
+/** What highlighting reads of a note. */
+export interface HighlightNote {
+ id: string;
+ kind: string;
+ body: string;
+ start_ms?: number | null;
+ end_ms?: number | null;
+ quoted_text?: string | null;
+}
+
+export interface NoteHighlights {
+ /** Each line's note marks, by its key; a line without any is absent. */
+ byLine: ReadonlyMap;
+ /** Where each selection note's words were found, by note id. */
+ byNote: ReadonlyMap;
+}
+
+const titleOf = (note: HighlightNote, resolution: Resolution) => {
+ const title = `Note: “${noteSnippet(note.body)}”`;
+ return resolution.state === "fuzzy"
+ ? `${title}. Written on “${note.quoted_text ?? ""}”, since reworded.`
+ : title;
+};
+
+/**
+ * The marks the selection notes put on the lines shown: each note resolved
+ * against them as they read now, its words marked when found exactly or with
+ * changes, and nothing marked when not found.
+ */
+export function noteHighlights(
+ notes: readonly HighlightNote[],
+ lines: readonly DrawnLine[],
+): NoteHighlights {
+ const byLine = new Map();
+ const byNote = new Map();
+ for (const note of notes) {
+ if (note.kind !== "selection") continue;
+ const resolution = resolveSelection(note, lines);
+ byNote.set(note.id, resolution);
+ const title = titleOf(note, resolution);
+ for (const range of resolution.ranges) {
+ const decoration: NoteDecoration = {
+ kind: "note",
+ from: range.from,
+ to: range.to,
+ noteId: note.id,
+ fuzzy: resolution.state === "fuzzy",
+ title,
+ };
+ const marks = byLine.get(range.segmentId);
+ if (marks) marks.push(decoration);
+ else byLine.set(range.segmentId, [decoration]);
+ }
+ }
+ return { byLine, byNote };
+}
+
+/** The text of `ranges` in `lines`, joined: what an unanchored note's span reads now. */
+export const textOfRanges = (
+ ranges: Resolution["bestGuess"],
+ lines: readonly DrawnLine[],
+): string => {
+ const textById = new Map(lines.map((line) => [line.segmentId, line.text]));
+ return ranges
+ .map((range) => textById.get(range.segmentId)?.slice(range.from, range.to) ?? "")
+ .filter((text) => text !== "")
+ .join(" ");
+};
diff --git a/frontend/src/components/transcript/textDecorations.ts b/frontend/src/components/transcript/textDecorations.ts
index 5987ae9ec..14229c0c1 100644
--- a/frontend/src/components/transcript/textDecorations.ts
+++ b/frontend/src/components/transcript/textDecorations.ts
@@ -15,7 +15,19 @@ export interface SearchDecoration {
current: boolean;
}
-export type TextDecoration = SearchDecoration;
+/** The words a selection note is on, as found in the text now (`resolveSelection`). */
+export interface NoteDecoration {
+ kind: "note";
+ from: number;
+ to: number;
+ noteId: string;
+ /** Found with words changed since the note was written. */
+ fuzzy: boolean;
+ /** What a pointer over it shows. */
+ title: string;
+}
+
+export type TextDecoration = SearchDecoration | NoteDecoration;
/** A stretch of text covered by the same decorations, in render order. */
export interface TextRun {
diff --git a/frontend/src/lib/notes/noteOutbox.ts b/frontend/src/lib/notes/noteOutbox.ts
index c5daab1ee..230dcd7da 100644
--- a/frontend/src/lib/notes/noteOutbox.ts
+++ b/frontend/src/lib/notes/noteOutbox.ts
@@ -273,6 +273,8 @@ export interface InlineNote {
end_ms?: number | null;
quoted_text?: string | null;
created_at: string;
+ /** A saved note's revision, which an edit names; absent while it is on its way. */
+ revision?: number;
status: "saved" | "saving" | "failed";
error: string | null;
}
diff --git a/frontend/src/lib/notes/resolveSelection.test.ts b/frontend/src/lib/notes/resolveSelection.test.ts
new file mode 100644
index 000000000..a0ba2de4d
--- /dev/null
+++ b/frontend/src/lib/notes/resolveSelection.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ANCHOR_MARGIN_MS,
+ ANCHOR_SCORE_FLOOR,
+ resolveSelection,
+ type Resolution,
+} from "./resolveSelection";
+import { interpolatedTokens, type DrawnLine } from "./selectionRange";
+
+/** A line from `startMs`, 100 ms a character, so a word's time is easy to read off. */
+const lineAt = (segmentId: string, startMs: number, text: string): DrawnLine => ({
+ segmentId,
+ text,
+ startMs,
+ endMs: startMs + 100 * text.length,
+});
+
+/** A note on characters `from`..`to` of `line`, timed as a selection there is (B3c). */
+const noteOn = (line: DrawnLine, from: number, to: number, quote = line.text.slice(from, to)) => {
+ const words = interpolatedTokens(line.text, line.startMs, line.endMs).filter(
+ (token) => token.from < to && token.to > from,
+ );
+ return { start_ms: words[0].startMs, end_ms: words[words.length - 1].endMs, quoted_text: quote };
+};
+
+/** The marked text, line by line. */
+const marked = (resolution: Resolution, lines: readonly DrawnLine[]) =>
+ resolution.ranges.map(({ segmentId, from, to }) => [
+ segmentId,
+ lines.find((line) => line.segmentId === segmentId)!.text.slice(from, to),
+ ]);
+
+const VENUE = lineAt("u1", 60_000, "We need forty thousand for the venue, and soon.");
+
+describe("resolveSelection", () => {
+ it("finds the quote as it was written: exact, on its words", () => {
+ const note = noteOn(VENUE, 8, 36);
+ const resolution = resolveSelection(note, [VENUE]);
+ expect(resolution.state).toBe("exact");
+ expect(resolution.score).toBe(1);
+ expect(marked(resolution, [VENUE])).toEqual([["u1", "forty thousand for the venue,"]]);
+ });
+
+ it("reads past case and punctuation as the same words", () => {
+ const now = lineAt("f1", 60_000, "We need Forty-thousand... no: forty thousand, for the VENUE and soon");
+ const resolution = resolveSelection({ ...noteOn(VENUE, 8, 36), quoted_text: "forty thousand for the venue" }, [now]);
+ expect(resolution.state).toBe("exact");
+ expect(marked(resolution, [now])).toEqual([["f1", "forty thousand, for the VENUE"]]);
+ });
+
+ it("finds the words after the final pass changed some: fuzzy, marked over the change", () => {
+ const note = noteOn(VENUE, 0, VENUE.text.length);
+ const now = lineAt("f1", 60_200, "We need fourteen thousand for the venue and soon.");
+ const resolution = resolveSelection(note, [now]);
+ expect(resolution.state).toBe("fuzzy");
+ expect(resolution.score).toBeCloseTo(1 - 1 / 9);
+ expect(marked(resolution, [now])).toEqual([["f1", now.text]]);
+ // However long the quote, one word changed is never exact.
+ const long = lineAt("u2", 60_000, "We need forty thousand for the venue, and soon, before the spring.");
+ const longNow = lineAt("f2", 60_000, "We need fourteen thousand for the venue, and soon, before the spring.");
+ expect(resolveSelection(noteOn(long, 0, long.text.length), [longNow])).toMatchObject({
+ state: "fuzzy",
+ score: 1 - 1 / 12,
+ });
+ });
+
+ it("finds words an ASR pass split, merged or dropped, while enough of them are left", () => {
+ // "gonna" → "going to" is two edits; "the whole" lost "whole", one more: three in ten words.
+ const live = lineAt("l1", 10_000, "so we're gonna ask the whole team about the venue");
+ const final = lineAt("f1", 10_000, "so we're going to ask the team about the venue");
+ const resolution = resolveSelection(noteOn(live, 0, live.text.length), [final]);
+ expect(resolution.state).toBe("fuzzy");
+ expect(resolution.score).toBeCloseTo(1 - 3 / 10);
+ expect(marked(resolution, [final])).toEqual([["f1", final.text]]);
+ });
+
+ it("finds a quote over two lines, however the new pass cut them, one range a line", () => {
+ const first = lineAt("l1", 0, "the budget is forty thousand");
+ const second = lineAt("l2", first.endMs, "for the venue in March");
+ const note = {
+ start_ms: noteOn(first, 14, 28).start_ms,
+ end_ms: noteOn(second, 0, 13).end_ms,
+ quoted_text: "forty thousand for the venue",
+ };
+ const recut = [lineAt("f1", 0, "the budget is forty"), lineAt("f2", 1_900, "thousand for the venue in March")];
+ const resolution = resolveSelection(note, recut);
+ expect(resolution.state).toBe("exact");
+ expect(marked(resolution, recut)).toEqual([
+ ["f1", "forty"],
+ ["f2", "thousand for the venue"],
+ ]);
+ });
+
+ it("never looks past the margin: the quote further away than that is unanchored", () => {
+ const note = noteOn(VENUE, 8, 13); // "forty", 60.8 s to 61.3 s
+ // Said earlier than the span, on a line of its own that ends 500 ms after it starts.
+ const earlier = (startMs: number) => [
+ lineAt("f1", startMs, "forty"),
+ lineAt("f2", 60_000, "We need a budget"),
+ ];
+ // It ends just inside the margin before the span, then just outside it.
+ const lineStartAtMargin = note.start_ms - ANCHOR_MARGIN_MS - 500;
+ const inside = resolveSelection(note, earlier(lineStartAtMargin));
+ expect(inside.state).toBe("exact");
+ const outside = resolveSelection(note, earlier(lineStartAtMargin - 1));
+ expect(outside.state).toBe("unanchored");
+ expect(outside.ranges).toEqual([]);
+ // And after it: it starts just inside the margin after the span, then just outside.
+ const later = (startMs: number) => [lineAt("f1", 60_000, "We need a budget"), lineAt("f2", startMs, "forty")];
+ expect(resolveSelection(note, later(note.end_ms + ANCHOR_MARGIN_MS)).state).toBe("exact");
+ expect(resolveSelection(note, later(note.end_ms + ANCHOR_MARGIN_MS + 1)).state).toBe("unanchored");
+ // Two and a half seconds after the span is too far.
+ expect(resolveSelection(note, later(note.end_ms + 2_500)).state).toBe("unanchored");
+ expect(resolveSelection(note, later(note.end_ms + 1_500)).state).toBe("exact");
+ });
+
+ it("shows what an unanchored note's span reads now, without marking it", () => {
+ const note = noteOn(VENUE, 8, 22);
+ const now = [lineAt("f1", 60_000, "We need a much bigger budget than that")];
+ const resolution = resolveSelection(note, now);
+ expect(resolution.state).toBe("unanchored");
+ expect(resolution.ranges).toEqual([]);
+ // Every word whose time touches 60.8 s to 62.2 s.
+ expect(resolution.bestGuess.map(({ from, to }) => now[0].text.slice(from, to))).toEqual([
+ "a much bigger budget",
+ ]);
+ });
+
+ it("puts a phrase said twice in the span on the one nearest the note's time", () => {
+ // Three times in 5.6 s, all well inside the margin of each other.
+ const line = lineAt("u1", 0, "yes we can, I said yes we can, and then yes we can again");
+ const second = line.text.indexOf("yes we can", 15);
+ const resolution = resolveSelection(noteOn(line, second, second + 10), [line]);
+ expect(resolution.state).toBe("exact");
+ // A mark runs to the end of a word's characters, its comma too.
+ expect(resolution.ranges).toEqual([{ segmentId: "u1", from: second, to: second + 11 }]);
+ const first = resolveSelection(noteOn(line, 0, 10), [line]);
+ expect(first.ranges).toEqual([{ segmentId: "u1", from: 0, to: 11 }]);
+ const third = line.text.lastIndexOf("yes we can");
+ expect(resolveSelection(noteOn(line, third, third + 10), [line]).ranges).toEqual([
+ { segmentId: "u1", from: third, to: third + 10 },
+ ]);
+ });
+
+ it("leaves an empty quote, or one of punctuation only, unanchored", () => {
+ for (const quoted_text of ["", " ", "… —", null]) {
+ const resolution = resolveSelection({ ...noteOn(VENUE, 8, 22), quoted_text }, [VENUE]);
+ expect(resolution).toMatchObject({ state: "unanchored", ranges: [], score: 0 });
+ }
+ });
+
+ it("finds a one-word quote only as that word: one changed word is not a fuzzy match", () => {
+ expect(resolveSelection(noteOn(VENUE, 8, 13), [VENUE]).state).toBe("exact");
+ const changed = lineAt("f1", 60_000, "We need fourty thousand for the venue, and soon.");
+ expect(resolveSelection(noteOn(VENUE, 8, 13), [changed])).toMatchObject({ state: "unanchored", ranges: [] });
+ });
+
+ it("marks a match at the floor, and none under it", () => {
+ expect(ANCHOR_SCORE_FLOOR).toBe(0.5);
+ // Two words, one heard differently: half of them, at the floor.
+ const atFloor = resolveSelection(noteOn(VENUE, 8, 22), [lineAt("f1", 60_000, "We need forty grand for the venue")]);
+ expect(atFloor.state).toBe("fuzzy");
+ expect(atFloor.score).toBeCloseTo(0.5);
+ expect(marked(atFloor, [lineAt("f1", 60_000, "We need forty grand for the venue")])[0][1]).toMatch(/^forty/);
+ // Five words, three changed: under it.
+ const quote = noteOn(VENUE, 8, 36);
+ const threeChanged = lineAt("f1", 60_000, "We need forty grand to a venue, and soon.");
+ const under = resolveSelection(quote, [threeChanged]);
+ expect(under.score).toBeCloseTo(0.4);
+ expect(under).toMatchObject({ state: "unanchored", ranges: [] });
+ });
+
+ it("follows the floor and margin it is given", () => {
+ const quote = noteOn(VENUE, 8, 36);
+ const twoChanged = lineAt("f1", 60_000, "We need forty grand for a venue, and soon.");
+ expect(resolveSelection(quote, [twoChanged]).score).toBeCloseTo(0.6);
+ expect(resolveSelection(quote, [twoChanged], { floor: 0.61 }).state).toBe("unanchored");
+ // "forty" ends 5 s before the span starts.
+ const far = [lineAt("f2", 60_800 - 5_000 - 500, "forty thousand")];
+ expect(resolveSelection(noteOn(VENUE, 8, 22), far, { marginMs: 5_000 }).state).toBe("exact");
+ expect(resolveSelection(noteOn(VENUE, 8, 22), far).state).toBe("unanchored");
+ });
+});
+
+/** A small seeded generator, so a failing case can be run again. */
+const random = (seed: number) => () => {
+ seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648;
+ return seed / 2_147_483_648;
+};
+
+const WORDS = [
+ "the", "budget", "venue", "forty", "thousand", "march", "we", "need", "council", "agreed",
+ "motion", "member", "question", "yes", "no", "and", "a", "to", "of", "report", "minister",
+ "noble", "lord", "safety", "water", "housing", "police", "transport", "time", "please",
+];
+
+const PUNCTUATION = ["", "", "", ",", ".", "?", "!", ";", "…"];
+
+describe("resolveSelection on random transcripts", () => {
+ /**
+ * A transcript of short lines, one word every 300-500 ms, and the same words
+ * cut into lines elsewhere, with punctuation and case changed: what a second
+ * pass over the same audio gives when it hears the same words.
+ */
+ const transcripts = (next: () => number) => {
+ const pick = (list: readonly T[]) => list[Math.floor(next() * list.length)];
+ const words = Array.from({ length: 40 + Math.floor(next() * 40) }, () => pick(WORDS));
+ const times: number[] = [];
+ let at = 1_000 + Math.floor(next() * 5_000);
+ for (let index = 0; index < words.length; index += 1) {
+ times.push(at);
+ at += 300 + Math.floor(next() * 200);
+ }
+ const cut = (variant: (word: string) => string): DrawnLine[] => {
+ const lines: DrawnLine[] = [];
+ let start = 0;
+ while (start < words.length) {
+ const end = Math.min(words.length, start + 3 + Math.floor(next() * 8));
+ lines.push({
+ segmentId: `s${lines.length}`,
+ text: words.slice(start, end).map(variant).join(" "),
+ startMs: times[start],
+ endMs: times[end - 1] + 300,
+ });
+ start = end;
+ }
+ return lines;
+ };
+ const then = cut((word) => word);
+ const now = cut((word) => {
+ const cased = next() < 0.3 ? word.toUpperCase() : next() < 0.3 ? word[0].toUpperCase() + word.slice(1) : word;
+ return cased + pick(PUNCTUATION);
+ });
+ return { then, now, pick };
+ };
+
+ /** A selection of a few words inside one line of `then`, as B3c makes it. */
+ const select = (then: readonly DrawnLine[], next: () => number) => {
+ const line = then[Math.floor(next() * then.length)];
+ const tokens = interpolatedTokens(line.text, line.startMs, line.endMs);
+ const count = 2 + Math.floor(next() * Math.min(5, tokens.length - 1));
+ const first = Math.floor(next() * (tokens.length - count + 1));
+ const last = Math.min(tokens.length, first + count) - 1;
+ return noteOn(line, tokens[first].from, tokens[last].to);
+ };
+
+ it("finds the quote, exactly or with changes, whatever the re-cut, case and punctuation (5,000 cases)", () => {
+ const next = random(488);
+ for (let run = 0; run < 5_000; run += 1) {
+ const { then, now } = transcripts(next);
+ const note = select(then, next);
+ const resolution = resolveSelection(note, now);
+ expect(resolution.state, JSON.stringify({ run, note })).not.toBe("unanchored");
+ }
+ });
+
+ it("never marks words when the quoted ones are gone from near its span (5,000 cases)", () => {
+ const next = random(3_888);
+ for (let run = 0; run < 5_000; run += 1) {
+ const { then, now } = transcripts(next);
+ const note = select(then, next);
+ // Near the note the new text is other words, from a list the quote never
+ // uses; further away it still says the same, quote and all.
+ const others = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"];
+ const near = (line: DrawnLine) =>
+ line.endMs >= note.start_ms - ANCHOR_MARGIN_MS - 1_000 &&
+ line.startMs <= note.end_ms + ANCHOR_MARGIN_MS + 1_000;
+ const replaced = now.map((line) =>
+ near(line)
+ ? {
+ ...line,
+ text: line.text
+ .split(" ")
+ .map((_, index) => others[(index + run) % others.length])
+ .join(" "),
+ }
+ : line,
+ );
+ const resolution = resolveSelection(note, replaced);
+ expect(resolution.ranges, JSON.stringify({ run, note })).toEqual([]);
+ expect(resolution.state).toBe("unanchored");
+ }
+ });
+});
diff --git a/frontend/src/lib/notes/resolveSelection.ts b/frontend/src/lib/notes/resolveSelection.ts
new file mode 100644
index 000000000..ed48db54d
--- /dev/null
+++ b/frontend/src/lib/notes/resolveSelection.ts
@@ -0,0 +1,189 @@
+import { interpolatedTokens, type DrawnLine } from "./selectionRange";
+
+/**
+ * Finds the words a selection note is on in the transcript as it reads now.
+ *
+ * A note keeps the span the words were selected at and a snapshot of the
+ * words (`start_ms`, `end_ms`, `quoted_text`). Reprocessing rebuilds the
+ * transcript, and the final pass writes different words from the live one,
+ * so the quote is looked for again on every render, and only near its span:
+ * among the words whose time falls within `marginMs` of it. Nothing found here
+ * is written back; the note keeps what the user saw.
+ *
+ * Words are compared case-folded with punctuation stripped. The match is the
+ * run of words with the fewest word edits from the quote (approximate
+ * substring matching, Sellers), scored `1 - edits / quote words`; a tie goes
+ * to the run nearest the span's middle. A score of 1 is `exact`; at least
+ * `floor` is `fuzzy`; anything lower is `unanchored`, and nothing is marked:
+ * a highlight on the wrong words would be worse than none. Word times are
+ * interpolated across each line (`interpolatedTokens`), as a selection's are.
+ */
+
+export type AnchorState = "exact" | "fuzzy" | "unanchored";
+
+/** Characters `from`..`to` (exclusive) of one line's text. */
+export interface LineRange {
+ segmentId: string;
+ from: number;
+ to: number;
+}
+
+export interface Resolution {
+ state: AnchorState;
+ /** The words matched, one range per line, in time order; empty when unanchored. */
+ ranges: LineRange[];
+ /** `1 - edits / quote words`, 0 when nothing matched. */
+ score: number;
+ /** The words now within the note's own span, for an unanchored note to show. */
+ bestGuess: LineRange[];
+}
+
+/** What resolution reads of a note. */
+export interface AnchoredNote {
+ start_ms?: number | null;
+ end_ms?: number | null;
+ quoted_text?: string | null;
+}
+
+/**
+ * How far either side of its span a note's words are looked for, and the
+ * lowest score a match is shown at. Calibrated on 7,500 selections of live
+ * and processed text of the benchmark recordings, resolved on their final and
+ * reprocessed text: past 2 s a wider margin finds almost nothing more and
+ * marks more wrong words; a floor of 0.6 would refuse 1.6% more, nearly all
+ * of them right (two-word quotes with one word changed), and one of 0.4 adds
+ * nothing.
+ */
+export const ANCHOR_MARGIN_MS = 2_000;
+export const ANCHOR_SCORE_FLOOR = 0.5;
+
+export interface ResolveOptions {
+ marginMs?: number;
+ floor?: number;
+}
+
+interface WindowToken {
+ segmentId: string;
+ from: number;
+ to: number;
+ startMs: number;
+ endMs: number;
+ word: string;
+}
+
+/** A word as compared: case-folded, letters and digits only. */
+export const comparableWord = (word: string): string =>
+ word.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
+
+const comparableWords = (text: string): string[] =>
+ text
+ .split(/\s+/)
+ .map(comparableWord)
+ .filter((word) => word !== "");
+
+/** Every word of `lines` timed within `[fromMs, toMs]`, lines in time order. */
+const tokensWithin = (lines: readonly DrawnLine[], fromMs: number, toMs: number): WindowToken[] =>
+ lines
+ .map((line, index) => ({ line, index }))
+ // A word's time is within its line's span, so no other line has one here.
+ .filter(({ line }) => line.endMs >= fromMs && line.startMs <= toMs)
+ .sort((left, right) => left.line.startMs - right.line.startMs || left.index - right.index)
+ .flatMap(({ line }) =>
+ interpolatedTokens(line.text, line.startMs, line.endMs)
+ .filter((token) => token.endMs >= fromMs && token.startMs <= toMs)
+ .map((token) => ({
+ segmentId: line.segmentId,
+ from: token.from,
+ to: token.to,
+ startMs: token.startMs,
+ endMs: token.endMs,
+ word: comparableWord(token.text),
+ }))
+ .filter((token) => token.word !== ""),
+ );
+
+/** Tokens as one range per line, in the order given. */
+const rangesOf = (tokens: readonly WindowToken[]): LineRange[] => {
+ const ranges: LineRange[] = [];
+ for (const token of tokens) {
+ const last = ranges[ranges.length - 1];
+ if (last && last.segmentId === token.segmentId) last.to = token.to;
+ else ranges.push({ segmentId: token.segmentId, from: token.from, to: token.to });
+ }
+ return ranges;
+};
+
+interface Match {
+ start: number;
+ end: number;
+ edits: number;
+}
+
+/**
+ * The run `tokens[start..end)` with the fewest word edits from `quote`
+ * (Sellers' approximate substring match), ties to the run whose middle is
+ * nearest `midMs`.
+ */
+const bestMatch = (quote: readonly string[], tokens: readonly WindowToken[], midMs: number): Match => {
+ const width = tokens.length + 1;
+ // Row i: the edits to match quote[0..i) ending at each token; a match may start anywhere.
+ let edits = new Array(width).fill(0);
+ let starts = Array.from({ length: width }, (_, end) => end);
+ for (let i = 1; i <= quote.length; i += 1) {
+ const nextEdits = new Array(width);
+ const nextStarts = new Array(width);
+ nextEdits[0] = i;
+ nextStarts[0] = 0;
+ for (let j = 1; j < width; j += 1) {
+ const substitute = edits[j - 1] + (quote[i - 1] === tokens[j - 1].word ? 0 : 1);
+ const skipQuoteWord = edits[j] + 1;
+ const extraWord = nextEdits[j - 1] + 1;
+ if (substitute <= skipQuoteWord && substitute <= extraWord) {
+ nextEdits[j] = substitute;
+ nextStarts[j] = starts[j - 1];
+ } else if (skipQuoteWord <= extraWord) {
+ nextEdits[j] = skipQuoteWord;
+ nextStarts[j] = starts[j];
+ } else {
+ nextEdits[j] = extraWord;
+ nextStarts[j] = nextStarts[j - 1];
+ }
+ }
+ edits = nextEdits;
+ starts = nextStarts;
+ }
+ let best: Match = { start: 0, end: 0, edits: quote.length };
+ let bestDistance = Infinity;
+ for (let end = 1; end < width; end += 1) {
+ const start = starts[end];
+ if (start >= end || edits[end] > best.edits) continue;
+ const distance = Math.abs((tokens[start].startMs + tokens[end - 1].endMs) / 2 - midMs);
+ if (edits[end] < best.edits || distance < bestDistance) {
+ best = { start, end, edits: edits[end] };
+ bestDistance = distance;
+ }
+ }
+ return best;
+};
+
+/** Where a selection note's words are now in `lines`: exact, fuzzy, or nowhere. */
+export function resolveSelection(
+ note: AnchoredNote,
+ lines: readonly DrawnLine[],
+ { marginMs = ANCHOR_MARGIN_MS, floor = ANCHOR_SCORE_FLOOR }: ResolveOptions = {},
+): Resolution {
+ const startMs = note.start_ms ?? 0;
+ const endMs = Math.max(startMs, note.end_ms ?? startMs);
+ const bestGuess = rangesOf(tokensWithin(lines, startMs, endMs));
+ const unanchored: Resolution = { state: "unanchored", ranges: [], score: 0, bestGuess };
+ const quote = comparableWords(note.quoted_text ?? "");
+ const tokens = tokensWithin(lines, startMs - marginMs, endMs + marginMs);
+ if (quote.length === 0 || tokens.length === 0) return unanchored;
+
+ const match = bestMatch(quote, tokens, (startMs + endMs) / 2);
+ const score = Math.max(0, 1 - match.edits / quote.length);
+ const state: AnchorState =
+ match.edits === 0 ? "exact" : score >= floor ? "fuzzy" : "unanchored";
+ if (state === "unanchored") return { ...unanchored, score };
+ return { state, ranges: rangesOf(tokens.slice(match.start, match.end)), score, bestGuess };
+}
diff --git a/frontend/src/lib/notes/useNoteEdits.ts b/frontend/src/lib/notes/useNoteEdits.ts
new file mode 100644
index 000000000..c7a4fed68
--- /dev/null
+++ b/frontend/src/lib/notes/useNoteEdits.ts
@@ -0,0 +1,125 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { deleteTimelineNote, updateTimelineNote } from "@/lib/api";
+import { getErrorStatus, isRecord } from "@/lib/errors";
+import type { RecordingId, TimelineNote } from "@/types";
+
+import { NOTE_BODY_MAX_CHARS, noteLength, stripNote } from "./headNoteSync";
+import { NOTE_TOO_LONG_ERROR, noteSnippet } from "./noteOutbox";
+
+/** What an edit reads of a note: a saved note has a revision. */
+export interface EditableNote {
+ id: string;
+ body: string;
+ revision?: number;
+}
+
+/** Why an edit or delete was refused, or null once it is done. */
+export type NoteEditRefusal = string | null;
+
+const NOT_SAVED_YET = "This note isn't saved yet.";
+const DELETED_ELSEWHERE = "This note was deleted elsewhere.";
+const NO_ANSWER = "No answer from Nojoin. Try again.";
+const REFUSED = "Nojoin refused this change.";
+
+const changedElsewhere = (note: TimelineNote, then: string) =>
+ `This note was changed elsewhere. It now reads “${noteSnippet(note.body, 80)}”. ${then}`;
+
+/** The note a 409 carries: the note as it is now on the server. */
+const conflictNote = (error: unknown): TimelineNote | null => {
+ if (getErrorStatus(error) !== 409 || !isRecord(error) || !isRecord(error.response)) return null;
+ const data = error.response.data;
+ if (!isRecord(data) || !isRecord(data.detail) || data.detail.code !== "revision_conflict") return null;
+ return isRecord(data.detail.note) ? (data.detail.note as unknown as TimelineNote) : null;
+};
+
+const isGone = (error: unknown) => {
+ const status = getErrorStatus(error);
+ return status === 404 || status === 410;
+};
+
+const otherFailure = (error: unknown): string => {
+ const status = getErrorStatus(error);
+ return status === null || status >= 500 ? NO_ANSWER : REFUSED;
+};
+
+/**
+ * Edits and deletes of a recording's saved notes from the finished view.
+ *
+ * Each write names the revision it was made against, so another device's
+ * change since is a 409 rather than overwritten; the 409 carries the note as
+ * it is now, which is shown from then on, and the editor keeps the text typed
+ * so a second Save replaces it knowingly. A write whose answer was lost and is
+ * sent again finds its own change there, and counts as done.
+ *
+ * A note's last known state is held here, over the listed notes (`apply`),
+ * since a finished recording isn't polled and its list isn't fetched again: a
+ * listing at a later revision wins, and a deleted note stays hidden.
+ */
+export function useNoteEdits(recordingId: RecordingId) {
+ const [held, setHeld] = useState>(() => new Map());
+ const hold = useCallback(
+ (id: string, note: TimelineNote | null) => setHeld((previous) => new Map(previous).set(id, note)),
+ [],
+ );
+
+ const apply = useCallback(
+ (notes: readonly N[]): N[] =>
+ notes.flatMap((note) => {
+ if (!held.has(note.id)) return [note];
+ const known = held.get(note.id);
+ if (!known) return [];
+ if (note.revision === undefined || note.revision >= known.revision) return [note];
+ return [{ ...note, body: known.body, revision: known.revision }];
+ }),
+ [held],
+ );
+
+ const edit = useCallback(
+ async (note: EditableNote, text: string): Promise => {
+ const body = stripNote(text);
+ if (note.revision === undefined) return NOT_SAVED_YET;
+ if (noteLength(body) > NOTE_BODY_MAX_CHARS) return NOTE_TOO_LONG_ERROR;
+ if (body === note.body) return null;
+ try {
+ hold(note.id, await updateTimelineNote(recordingId, note.id, {
+ expected_revision: note.revision,
+ body,
+ }));
+ return null;
+ } catch (error) {
+ const current = conflictNote(error);
+ if (current) {
+ hold(note.id, current);
+ return current.body === body ? null : changedElsewhere(current, "Save again to keep yours.");
+ }
+ return isGone(error) ? DELETED_ELSEWHERE : otherFailure(error);
+ }
+ },
+ [recordingId, hold],
+ );
+
+ const remove = useCallback(
+ async (note: EditableNote): Promise => {
+ if (note.revision === undefined) return NOT_SAVED_YET;
+ try {
+ await deleteTimelineNote(recordingId, note.id, { expectedRevision: note.revision });
+ } catch (error) {
+ const current = conflictNote(error);
+ if (current) {
+ hold(note.id, current);
+ return changedElsewhere(current, "Delete again to remove it anyway.");
+ }
+ // Gone already: deleted elsewhere, or by this delete's lost answer.
+ if (!isGone(error)) return otherFailure(error);
+ }
+ hold(note.id, null);
+ return null;
+ },
+ [recordingId, hold],
+ );
+
+ return { apply, edit, remove };
+}
From 5df84b4cb4acee146457318463090a4c51809cd8 Mon Sep 17 00:00:00 2001
From: Mike Mulligan <130580+thebeline@users.noreply.github.com>
Date: Fri, 23 Oct 2026 12:39:12 -0400
Subject: [PATCH 2/6] fix(notes): find a quote cut inside a word; hold the page
while an edit is unsent
A selection's quote keeps just the characters chosen (B3c), so a drag
that starts or ends inside a word, ordinary in Firefox and Safari, stores
"ousand for the ve". The resolver compared whole words only, so such a
note read "Words changed since this note" straight after it was saved.
Now the quote's first word also matches the end of a word, its last word
the start of one, and a one-word quote any part of one, with no edit
counted. A whole word wins a tie over a part word, so "the" lands on "the"
rather than inside "breathe". The stored quote is unchanged.
Editing or deleting a saved note now holds the page as an unsent note
does: from Save or Delete until the write is confirmed or the editor is
closed, a refused write still open included. The hold lives in memory
beside the outboxes (holdPendingNoteWrite); nothing retries it. A refused
write puts focus back in the box, as disabling the pressed button dropped
it to the page, and the reason is announced as an alert.
The note underline (action-text on action-tint) is now in the contrast
gate at 3:1; it is what marks the words on a light card.
Calibration, margin and floor frozen at 2 s and 0.5. All figures are on the
dev fixtures (Lords, London, Seattle) the margin and floor were tuned on,
not on held-out data. On the 7,500 tuning selections the part-word rule
leaves 94.9% marked right; wrong marks go from 1.5% to 1.7%, and unanchored
from 3.7% to 3.5%. On 3,000 drags added after the freeze (inside words,
over two or three lines, and over 2,000 characters, so shortened), right
goes from 84.9% to 97.2%, unanchored from 14.8% to 1.5%, and wrong from
0.3% to 1.3%. Drags inside one line go from 10% to 83% exact. The wrong
marks are part-word quotes of one to three words, mostly one-word ones that
land on a neighbouring word holding the same letters. Limiting a one-word
part match to the note's own span cut those from 12.8% to 8.4% of one-word
drags but lost 12 points of right ones, so it is not done.
---
docs/ARCHITECTURE.md | 4 +-
docs/USAGE.md | 4 +-
frontend/scripts/check-contrast.mjs | 3 +
.../TranscriptSection.highlights.test.tsx | 57 ++++
.../notes/NoteOnSelectionPopover.tsx | 12 +-
.../transcript/TranscriptNoteEditor.tsx | 15 +-
frontend/src/lib/notes/noteOutboxes.ts | 28 +-
.../src/lib/notes/resolveSelection.test.ts | 259 ++++++++++++++++--
frontend/src/lib/notes/resolveSelection.ts | 61 +++--
9 files changed, 392 insertions(+), 51 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d2f7f6545..ad6a63d0d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -359,14 +359,14 @@ The user's notes are `timeline_notes` rows (`backend/models/timeline_note.py`) a
- **Reading them.** The detail read carries `timeline_notes_list_signature` (`count:hash` of each note's id and revision), not the notes, and the page fetches the list when it changes (`frontend/src/lib/notes/useTimelineNotes.ts`), and after its own writes when nothing polls. Every write that changes a listed note makes a new revision, so the signature changes whenever the list does.
- **Placement.** One rule, implemented twice: `place_timeline_notes` (`backend/utils/timeline_notes.py`) for prompts, and `placeTimelineNotes` (`frontend/src/lib/notes/placement.ts`) for the live and finished transcripts, both pinned by `backend/tests/fixtures/timeline_note_placement_cases.json`. A note at t (a selection at its last millisecond) follows the last non-blank line, in list order, starting at or before t. A note before the first line opens the transcript.
- **Views** place notes on their whole line list. A note after a hidden line shows after the visible line before it, and a note ends the run of grouped lines it follows. The views sort lines by start, while the prompts keep the stored order, which every writer emits by start: lines tied on start may sort either way without moving a note past a non-blank line (a property test in `placement.test.ts`). A list stored out of start order, which `PUT /transcripts/{id}/segments` accepts, would place a note differently in the views than in the prompts.
-- **Highlights.** A selection's `quoted_text` is a snapshot of the words the user saw, and its `start_ms`/`end_ms` are where they were. The processed view finds them again on every render, never writing anything back (`frontend/src/lib/notes/resolveSelection.ts`). The rule: look only among the words timed within 2 s of the span, compare them case-folded with punctuation stripped, and take the run with the fewest word edits from the quote, ties to the run nearest the span's middle. All words equal is *exact*; a score `1 − edits/quote words` of at least 0.5 is *fuzzy*; anything less is *unanchored*, and nothing is marked. A reprocess, a line edit or Find & Replace therefore never moves a note or its placement, which reads only the stored span, and the prompts keep the stored quote. Utterance ids and character offsets are never stored on a note, since a reprocess replaces both.
+- **Highlights.** A selection's `quoted_text` is a snapshot of the words the user saw, and its `start_ms`/`end_ms` are where they were. The processed view finds them again at read time (memoised on lines and notes), never writing anything back (`frontend/src/lib/notes/resolveSelection.ts`). The rule: look only among the words timed within 2 s of the span, compare them case-folded with punctuation stripped, and take the run with the fewest word edits from the quote, ties to the run with fewer part-word matches, then to the run nearest the span's middle. A quote keeps just the characters selected, so it may start or end inside a word: its first word also matches the end of a word, its last word the start of one, and a one-word quote any part of one, with no edit counted. All words equal is *exact*; a score `1 − edits/quote words` of at least 0.5 is *fuzzy*; anything less is *unanchored*, and nothing is marked. A reprocess, a line edit or Find & Replace therefore never moves a note or its placement, which reads only the stored span, and the prompts keep the stored quote. Utterance ids and character offsets are never stored on a note, since a reprocess replaces both.
- **Prompts.** An anchored note is its own line after the line it follows, folded so it cannot pass for transcript. Head and tail notes go in `# User Notes Context`, quoted with `> `; chat puts them first and last. Speaker inference reads every note as a timestamped list. Generated notes end with `## User Notes`, which speaker renames never rewrite.
- **Meeting Edge** refreshes at once, ahead of its throttle, when `timeline_notes_signature` changes. It hashes each note's id and content (kind, text, anchors, quote, author, creation time), not its revision as the list signature does, so an edit that changes none of these refreshes nothing.
- **Revisions.** An edit names its base revision, and a delete may (`?expected_revision=`). A stale one is a 409 `revision_conflict` with the current note.
- **Write tokens.** A write may carry a client-chosen `write_token`, and the note reports the one that made its revision (`last_write_token`), so a client recognises its own write. An edit with a token always makes a new revision, fencing off an earlier write still in transit.
- **Tombstones.** A delete leaves a `timeline_note_tombstones` row with its token, so later writes to that id get a 410 (`note_deleted_by_you` or `note_deleted_elsewhere`) and a retried create never revives the note. A delete with a token of an unknown id leaves a `never_held` tombstone (kept an hour, at most 20 per recording), so a create whose answer was lost cannot outlive the user's clear. Client-id creates and deletes take `pg_advisory_xact_lock(hashtext(id))`. Backups omit tombstones, and a restored note whose id is tombstoned gets a fresh id.
- **Errors** carry `detail: {code, message}`. Clients branch on `code`, pinned by `backend/tests/fixtures/timeline_note_error_codes.json`. The notes box's sync states are tabulated in `frontend/src/lib/notes/headNoteSync.ts`, and the live note box's in `frontend/src/lib/notes/noteOutbox.ts`. The live box's outbox is one per recording for the life of the page, so notes on their way and the box's text outlive the live view. A note on a line from its ⋯ menu, in the live or the processed transcript, goes through the same outbox as a `selection` note quoting that line. So does a note on selected text, whose `start_ms`/`end_ms` are estimated: word times don't reach the client, so each word's time is interpolated by character offset across its line's span (`frontend/src/lib/notes/selectionRange.ts`), and the span covers every word the selection touches. Its `quoted_text` is the text as selected.
-- **Unsaved notes in the browser.** Each outbox is also kept in IndexedDB (`nojoin-notes`), one record per account id and recording holding each unsaved note with its id and write token, the box's text and any discard whose delete is unconfirmed (`frontend/src/lib/notes/notePersistence.ts`). The id comes from `/users/me` and survives a rename. Once the session names the account, opening the recording again reads its record back and sends each note under the same id and token, so a create that landed before a reload returns its note rather than a second one, and a 409 or 410 is handled as on the first attempt; after a 409 the note goes out under its new id only once the record has it, or once a write under way has gone unanswered for 2 s (`NEW_ID_WAIT_MS`): a tab that closes or crashes before that write answers can leave the old id stored (a close no longer asks first, as the note has gone out), and since the server refuses that id again whatever the token, a reload makes a second copy of the note (it takes an id clash with another recording, a stalled store and the tab closing or crashing, together). A page writes only while its session is the account it read as, no sign-out has stopped storing, and no cleanup has removed that account's records since. Each `/users/me` drops every other account's records. The tab tells the others over a BroadcastChannel (`nojoin-notes`), as it does a sign-out, which stops every tab storing until a sign-in is accepted in it; a tab told of either repeats the removal for its own account, after any write it had begun, so a write that landed between the removal and the ping doesn't outlive it; without BroadcastChannel, other tabs go by their own sessions. Two tabs on one recording share the record, and the last to write wins. The record is therefore only a stopgap for a crash, a killed tab or an ended session: the leave warning (`noteOutboxes.ts`) is armed whenever anything is unsent, whatever is stored (browsers that never show it, such as iOS Safari, have only the stored copy), and the sign-in dialog's way to the sign-in page goes through it. Records carry a `version`: an earlier version's record is upgraded one step at a time through `RECORD_MIGRATIONS` and left alone where a step is missing, a later version's is neither read nor written, and a note of a kind this version cannot send is written back unchanged. A new kind is therefore not a new version: `selection` notes add `startMs`, `endMs` and `quotedText` to their own stored notes only, and an older build keeps them as they are. A browser without IndexedDB, or one that refuses, keeps the outbox in memory only. While any outbox holds something unsaved, the page holds sign-in in place as a capture does (Session expiry, under [Browser Capture Stack](#browser-capture-stack)), so a device that only takes notes asks for the password instead of reloading to `/login`.
+- **Unsaved notes in the browser.** Each outbox is also kept in IndexedDB (`nojoin-notes`), one record per account id and recording holding each unsaved note with its id and write token, the box's text and any discard whose delete is unconfirmed (`frontend/src/lib/notes/notePersistence.ts`). The id comes from `/users/me` and survives a rename. Once the session names the account, opening the recording again reads its record back and sends each note under the same id and token, so a create that landed before a reload returns its note rather than a second one, and a 409 or 410 is handled as on the first attempt; after a 409 the note goes out under its new id only once the record has it, or once a write under way has gone unanswered for 2 s (`NEW_ID_WAIT_MS`): a tab that closes or crashes before that write answers can leave the old id stored (a close no longer asks first, as the note has gone out), and since the server refuses that id again whatever the token, a reload makes a second copy of the note (it takes an id clash with another recording, a stalled store and the tab closing or crashing, together). A page writes only while its session is the account it read as, no sign-out has stopped storing, and no cleanup has removed that account's records since. Each `/users/me` drops every other account's records. The tab tells the others over a BroadcastChannel (`nojoin-notes`), as it does a sign-out, which stops every tab storing until a sign-in is accepted in it; a tab told of either repeats the removal for its own account, after any write it had begun, so a write that landed between the removal and the ping doesn't outlive it; without BroadcastChannel, other tabs go by their own sessions. Two tabs on one recording share the record, and the last to write wins. The record is therefore only a stopgap for a crash, a killed tab or an ended session: the leave warning (`noteOutboxes.ts`) is armed whenever anything is unsent, whatever is stored, and so is an edit or delete of a saved note from its Save or Delete until it is confirmed or its editor closes, a refused one still open included (`holdPendingNoteWrite`, in memory only, never retried; it holds sign-in in place too) (browsers that never show it, such as iOS Safari, have only the stored copy), and the sign-in dialog's way to the sign-in page goes through it. Records carry a `version`: an earlier version's record is upgraded one step at a time through `RECORD_MIGRATIONS` and left alone where a step is missing, a later version's is neither read nor written, and a note of a kind this version cannot send is written back unchanged. A new kind is therefore not a new version: `selection` notes add `startMs`, `endMs` and `quotedText` to their own stored notes only, and an older build keeps them as they are. A browser without IndexedDB, or one that refuses, keeps the outbox in memory only. While any outbox holds something unsaved, the page holds sign-in in place as a capture does (Session expiry, under [Browser Capture Stack](#browser-capture-stack)), so a device that only takes notes asks for the password instead of reloading to `/login`.
- **Unsent marks.** The recordings rail marks each recording with something unsaved in this browser (`useUnsentRecordings` in `frontend/src/lib/notes/useNoteOutbox.ts`). A recording this page holds an outbox for goes by that outbox; any other by whether the signed-in account has a record for it (`NoteStore.recordingsOf`), read when the page loads and again whenever this page's outboxes change, so another tab's writes show at the next read. Nothing is sent from the rail: opening the recording restores and sends as above, because a sender in the background would act on notes another tab may have open. Deleting a recording for good or discarding it here (`forgetRecordingNotes` in `noteOutboxes.ts`, called by the recording actions, the batch bar and a recording page that finds its recording gone for its owner) drops the page's outbox for it once any write of it under way has finished, removes the signed-in account's record, and tells the other tabs (`forgot` on the `nojoin-notes` channel), which drop their outbox for it and remove the record again. A record for a recording deleted on another device is never marked. Once per page load and account, when the rail's list first loads, `useForgetNotesOfDeletedRecordings` (`frontend/src/lib/notes/deletedRecordingNotes.ts`) checks the account's records whose recording is neither listed nor held by an outbox here, at most 20, two at a time, with `GET /recordings/{id}` (issue #636). A 404 `Recording not found` between two `/users/me` answers naming the record's account forgets it through `forgetRecordingNotes`, since under another account's session cookie the owner's recordings answer 404 too. Any other answer keeps it, a binned recording's 200 included; `backend/tests/test_recording_detail_after_delete.py` pins those answers.
### Imported Media Input
diff --git a/docs/USAGE.md b/docs/USAGE.md
index c3d2f838b..64ccdbb15 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -129,8 +129,8 @@ In the transcript:
- Notes you placed during the meeting ([Live Notes](#live-notes)) show on their own line after the line they follow. When a line has two or more saved notes, they fold into a count, such as **2 notes**, which shows them when you click or tap it; a single note shows as it is. A note you write on this page stays shown once it saves, and one not saved yet always shows.
- The **⋯** button beside a speaker's name opens the line's menu: **Note on this line**, **Edit text**, **Change speaker** and **Play from here**, which plays on rather than stopping at the line's end. It shows when you point at the line or tab to it, and always on a touch screen, where pressing and holding the speaker's name also opens it. In the menu, the arrow keys move and Escape closes it. A note on a line quotes the line's words; Enter saves it and Shift+Enter starts a new line. One that can't be saved waits on its line with **Retry** and **Discard**.
- Select some text, in one line or across several, for a bar with **Add note** and **Copy**. A note on selected text quotes those words, and its time runs from the first word you touched to the last, estimated from the line's start and end. Escape or a click elsewhere puts the bar away. A note you have started typing stays open, whatever you click or select, until you save or cancel it.
-- The words a note is on are tinted and underlined in the transcript. After processing or a reprocess the words may read a little differently: Nojoin looks for them again near the note's time, and words found with changes are underlined with dots instead, the original words shown when you point at them. When they can no longer be found, nothing is marked, and the note shows its quote struck through, says the words changed, and quotes what the transcript says at that time now.
-- Click tinted words, or the pencil beside a saved note, to edit the note or delete it. If the note was changed on another device meanwhile, Nojoin shows its new text and keeps yours in the box: save again to replace it.
+- The words a note is on are underlined in the transcript, on a light tint. After processing or a reprocess the words may read a little differently: Nojoin looks for them again near the note's time, and words found with changes are underlined with dots instead, the original words shown when you point at them. When they can no longer be found, nothing is marked, and the note shows its quote struck through, says the words changed, and quotes what the transcript says at that time now.
+- Click underlined words, or the pencil beside a saved note, to edit the note or delete it. If the note was changed on another device meanwhile, Nojoin shows its new text and keeps yours in the box: save again to replace it. Until a change or delete is saved, or you cancel it, Nojoin asks before you close the page.
**Export** saves the notes, analytics and transcript as one PDF, Word or text file, or the audio as MP3 ([Exporting A Meeting](#exporting-a-meeting)).
diff --git a/frontend/scripts/check-contrast.mjs b/frontend/scripts/check-contrast.mjs
index a5fddbdd1..6788fc8f3 100644
--- a/frontend/scripts/check-contrast.mjs
+++ b/frontend/scripts/check-contrast.mjs
@@ -120,6 +120,9 @@ const PAIRINGS = [
{ label: "action text hover on card", fg: "action-text-hover", bg: ["surface-page", "surface-card"], min: AA_TEXT },
{ label: "tint text on tint (on card)", fg: "action-tint-fg", bg: ["surface-page", "surface-card", "action-tint"], min: AA_TEXT },
{ label: "tint text on hover tint (on card)", fg: "action-tint-fg", bg: ["surface-page", "surface-card", "action-tint-hover"], min: AA_TEXT },
+ // A note's words in the transcript: the underline is what marks them, since
+ // the tint alone barely shows on a light card.
+ { label: "note underline on tint (on card)", fg: "action-text", bg: ["surface-page", "surface-card", "action-tint"], min: AA_NON_TEXT },
{ label: "action border vs card", fg: "action-border", bg: ["surface-page", "surface-card"], min: HAIRLINE },
// Danger family, held to the same rules as the action family.
diff --git a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
index 69dc4be75..5826a952b 100644
--- a/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
+++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx
@@ -316,4 +316,61 @@ describe("editing a note in the processed transcript", () => {
expect(noteLine("n-exact")).toHaveTextContent("Changed on my phone");
expect(marks()).toHaveLength(1);
});
+
+ it("puts focus back in the box when a save is refused, and says why as an alert", async () => {
+ updateTimelineNote.mockRejectedValueOnce({ isAxiosError: true, message: "Network Error" });
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } });
+ const save = within(editor).getByRole("button", { name: "Save note" });
+ save.focus();
+ fireEvent.click(save);
+
+ expect(await within(editor).findByRole("alert")).toHaveTextContent("No answer from Nojoin. Try again.");
+ expect(document.activeElement).toBe(within(editor).getByRole("textbox"));
+ });
+});
+
+describe("leaving the page with a note's change unsaved", () => {
+ const leave = () => {
+ const event = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(event);
+ return event.defaultPrevented;
+ };
+ const openFromLine = (id: string, body: string) => {
+ fireEvent.click(within(noteLine(id)).getByRole("button", { name: `Edit note at 01:05, “${body}”` }));
+ return screen.getByRole("dialog", { name: "Edit the note at 01:05" });
+ };
+
+ it("asks while an edit is on its way, and not once it is saved", async () => {
+ let answer: (note: TimelineNote) => void = () => undefined;
+ updateTimelineNote.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ expect(leave()).toBe(false);
+ fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } });
+ fireEvent.click(within(editor).getByRole("button", { name: "Save note" }));
+ expect(leave()).toBe(true);
+
+ answer({ ...EXACT, body: "Mine", revision: 4 });
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(leave()).toBe(false);
+ });
+
+ it("asks while a refused delete is still open, and not once it is given up", async () => {
+ deleteTimelineNote.mockRejectedValueOnce(conflict({ ...EXACT, body: "Changed on my phone", revision: 5 }));
+ renderSection([EXACT]);
+
+ const editor = openFromLine("n-exact", "Check the figure");
+ fireEvent.click(within(editor).getByRole("button", { name: "Delete" }));
+ fireEvent.click(within(editor).getByRole("button", { name: "Confirm delete" }));
+ await within(editor).findByRole("alert");
+ expect(leave()).toBe(true);
+
+ fireEvent.click(within(editor).getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(leave()).toBe(false);
+ });
});
diff --git a/frontend/src/components/notes/NoteOnSelectionPopover.tsx b/frontend/src/components/notes/NoteOnSelectionPopover.tsx
index aec2c10b6..be4b28c02 100644
--- a/frontend/src/components/notes/NoteOnSelectionPopover.tsx
+++ b/frontend/src/components/notes/NoteOnSelectionPopover.tsx
@@ -90,9 +90,15 @@ export default function NoteOnSelectionPopover({
return () => document.removeEventListener("pointerdown", closeOnOutsidePress, true);
}, [isUnchanged, anchor, onClose, panelRef]);
+ // A refused write keeps the popover open. The button pressed was disabled
+ // while it ran, which dropped focus to the page, so it goes to the box.
const finish = (refusal: Refusal) => {
- if (refusal) setError(refusal);
- else close();
+ if (!refusal) {
+ close();
+ return;
+ }
+ setError(refusal);
+ boxRef.current?.focus();
};
/**
@@ -198,7 +204,7 @@ export default function NoteOnSelectionPopover({
className="density-surface-panel mt-2 w-full resize-none border border-control-border bg-control-bg px-3 py-2 text-sm leading-5 text-contrast-muted outline-none focus:border-action focus:ring-2 focus:ring-action"
/>
{error ? (
-