diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd565583..6123984a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -369,13 +369,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 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 357d2a06..537f23f4 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 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. An edit you have started typing stays open until you save or cancel 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. From **Save note** or **Confirm delete** until the change 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 a5fddbdd..6788fc8f 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 new file mode 100644 index 00000000..1a2e5cec --- /dev/null +++ b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.highlights.test.tsx @@ -0,0 +1,406 @@ +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("keeps an editor holding typed text when another note's words are pressed", () => { + // On the line before, so neither note folds into a count. + const morning: TimelineNote = { + ...EXACT, + id: "n-morning", + body: "Say hello", + start_ms: 0, + end_ms: 2_000, + quoted_text: "Good morning", + }; + renderSection([EXACT, morning]); + const other = () => document.querySelector('mark[data-note-id="n-morning"]')!; + const press = (element: HTMLElement) => { + fireEvent.pointerDown(element); + fireEvent.click(element); + }; + + const editor = openFromLine("n-exact", "Check the figure"); + fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Mine" } }); + press(other()); + expect(within(screen.getByRole("dialog", { name: "Edit the note at 01:05" })).getByRole("textbox")).toHaveValue( + "Mine", + ); + + // Back as it opened, it gives way to the words pressed. + fireEvent.change(within(editor).getByRole("textbox"), { target: { value: "Check the figure" } }); + press(other()); + expect(within(screen.getByRole("dialog")).getByRole("textbox")).toHaveValue("Say hello"); + }); + + 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); + }); + + 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/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx b/frontend/src/app/(dashboard)/recordings/[id]/_components/TranscriptSection.tsx index 631ed2ed..acde0f12 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 04a438b9..6e861e38 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,32 @@ 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); + // The editor shows only while its note is a saved one in the list. + const shownEditor = + editingNote !== null && + (timelineNotes ?? []).some((note) => note.id === editingNote.noteId && isSavedNote(note)) + ? editingNote + : null; + // An editor still open holds typed text or a write, so another note's marks + // or pencil never replace it; one left as it opened has already closed on + // the press (`NoteOnSelectionPopover`). + const openNoteEditor = (next: EditingNote) => + setEditingNote((open) => (open !== null && open === shownEditor ? open : next)); + 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; + openNoteEditor({ noteId, anchor: mark }); + }; const renderNote = (note: TimelineNote | InlineNote) => { const key = "key" in note ? note.key : note.id; return ( @@ -232,6 +266,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) => openNoteEditor({ noteId: note.id, anchor: trigger }) + : undefined + } timeLabel={formatTime( (note.kind === "selection" ? (note.start_ms ?? 0) : (note.anchor_ms ?? 0)) / 1000, )} @@ -335,6 +375,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 ? (
@@ -380,6 +421,7 @@ export default function TranscriptView({ )} recentLineKeys={lineKeys(group.items, isRecentlyUpdatedSegment)} searchDecorations={search.searchDecorations} + noteDecorations={noteDecorations} editingSpeakerLineId={editing.editingSpeakerLineId} editingTextId={editing.editingTextId} activePopover={editing.activePopover} @@ -399,6 +441,15 @@ export default function TranscriptView({ ]}
+ {onEditNote ? ( + setEditingNote(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/notes/NoteOnSelectionPopover.tsx b/frontend/src/components/notes/NoteOnSelectionPopover.tsx index cfb2af59..be4b28c0 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,48 @@ export default function NoteOnSelectionPopover({ }; document.addEventListener("pointerdown", closeOnOutsidePress, true); return () => document.removeEventListener("pointerdown", closeOnOutsidePress, true); - }, [isBlank, anchor, onClose, panelRef]); + }, [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) { + close(); + return; + } + setError(refusal); + boxRef.current?.focus(); + }; + + /** + * 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 +156,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 +182,11 @@ export default function NoteOnSelectionPopover({

{title}

-
- {selection.quotedText} -
+ {quote ? ( +
+ {quote} +
+ ) : null}