diff --git a/AGENTS.md b/AGENTS.md index bd60cf76..5d0b17cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,6 +328,13 @@ Frontend and shell: - `Frontend/client/src/main.tsx` starts the initial locale catalog and only the selected main/tray/overlay module together, then waits for both before React renders. Keep the overlay's transparent document markers ahead of its import. + Main and tray also prefetch their bounded initial native backend access during + module loading. Reuse that startup promise at mount without blocking the + localized loader; normal health/restart checks must still refresh access. + Idle Settings preloading publishes the settings resource independently of + microphone discovery. Before writing the query cache, verify the bootstrap + generation and unchanged query state so a late preload cannot replace a save + or an independent query response, even within the same millisecond. - `Frontend/client/src/hooks/use-backend-status.tsx` subscribes to the native `backend-status-changed` invalidation before its initial authoritative query. Coalesce concurrent checks and retain one follow-up for events received during @@ -762,7 +769,15 @@ Packaging and scripts: single-instance show actions must reveal that same WebView again; do not leave a headless tray process whose main window no longer exists. Explicit tray Quit and app exit still use the bounded graceful backend/audio cleanup path. -- The custom tray WebView refreshes recent transcripts on every native show +- The custom tray WebView keeps all action targets visible without a scroll + container. Create it from an async command or background event task because + synchronous WebView2 creation can deadlock Windows IPC. Serialize competing + show requests. Rows share + the available height; supplementary descriptions collapse before actions. + The native window fits the monitor work area in logical pixels, including + high-DPI displays. Verify normal, update, error, and recent-history views + at 668, 560, and 480 logical pixels high when changing this layout. It refreshes + recent transcripts on every native show through `scriber-tray-opened`, and after installing its listener on first startup. Abort and generation guards prevent older requests from replacing newer history. The Transcript routes' `/recent` and `/{id}/copy` reads use @@ -772,6 +787,14 @@ Packaging and scripts: Labels are bounded plain-text previews; empty text stays visibly disabled. Copy validates the selected ID and final status again, runs off the UI dispatcher, and uses an owned Scriber HWND for Windows clipboard ownership. + Entering Recent Transcripts may reuse an in-flight read from that opening; + a new native opening must still invalidate it. Refresh shortcut labels on + opening, never on recording/update status events. Subscribe before reading + initial tray status, bound listener setup, and reject late snapshots after + newer events. Native unchanged status skips icon/menu/event work; app-wide + status delivery already reaches the tray and must not be duplicated. + A failed or stalled opened-listener registration must still perform the + initial shortcut/history read. Dispose timers and late listeners on unmount. - Rust registers both live-mic shortcuts and the Meeting shortcut after the token-protected backend identity is ready. Fresh installs default to `Ctrl+Shift+D` for Live Mic, `Ctrl+Shift+F` for post-processing, and @@ -2147,7 +2170,81 @@ Already implemented and should not be regressed: provider replay evidence. - Canvas/RAF waveform drawing instead of per-frame React state. - Buffered transcript appends for long live sessions. +- Timed file stitching retires only the sorted prefix ending strictly before + the next part. Preserve conservative overlap evidence, speakers and warnings; + compare changes with the frozen reference in the merge diagnostic benchmark. + Keep two alignment score rows and byte-sized traceback moves, without + truncating difficult overlaps. Tests bound timestamp reads and traced memory. +- Summary contents navigation reuses heading elements and reads current + geometry with binary search for ordinary block headings. Preserve the linear + fallback for tables/snapshot grids and refresh references on summary changes. +- Saved Meeting playback reuses `createReviewPlaybackLookup` per segment + snapshot; preserve canonical/alignment/start-time priority and stable ties. + Do not build the interval index for live previews without saved playback audio. + Keep transcript edit/undo callbacks and virtualizer item keys stable, and + avoid whole-transcript scans on playback ticks. `Meetings.test.tsx` gates zero + row renders within an unchanged active segment; the timeline tests gate at + most 64 timestamp reads per seek on 12,000 overlapping synthetic segments. + `npm run benchmark:meeting-review` reports informational CPU timings and + index construction cost; it is not an installed Windows latency claim. +- Settings renders persisted values independently of microphone discovery and + native autostart. Preserve shared bootstrap requests, cache expiry, generation + guards, and in-flight detachment on invalidation; late peripheral results must + not overwrite user edits or notify after unmount. +- Settings keeps custom vocabulary, the summary and live-cleanup prompts, and + both custom OpenRouter model codes in `lib/text-draft-store.ts` stores, not + page state. A keystroke renders only its field. Loads, resets, rollback and + canonicalization write the store; blur saves and "Use" read it. Vocabulary + autosave and localized-default prompt replacement subscribe to their store + with the former debounce/recheck semantics. Tests require zero page-wide + tooltip renders while typing. +- The Visualizer-bars Settings control owns drag state and its saved-value + reference. Commit through the existing Settings write queue. Failed earlier + writes must not overwrite a newer draft, and reverting to the saved value + behind an outstanding write must still persist that revert. +- `VirtualTranscriptHistory` is memoized; Live Mic/YouTube/File must keep both + row-render callbacks and next-page callbacks stable across unrelated inputs. + Include all row-visible action state in callback dependencies. Preserve + internal scroll/resize updates, replacement data, locale and layout changes. +- Live Mic interim text, elapsed time, status and warnings stay inside the + memoized `LiveMicStage`; history query, search and cards stay in the page. + The stage receives only the history refresh callback for a finished session. +- The Meetings page owns each note queue, pagehide flush and autosave delay + without subscribing to drafts; `MeetingNotesEditor` subscribes through + `useMeetingNotesAutosaveSnapshot`. Restart the delay only when draft text or + dirtiness changes, as the former render-driven effect did. The page-owned + chat question is a draft store read by `MeetingChatComposer`; Meeting + switches and successful answers still clear it, view switches keep it. +- Meeting transcript rows keep separate memo boundaries. Pass draft/saving + state only to the affected row, and keep playback callbacks independent of + unrelated detail changes. The page-local display projector consumes immutable + segment snapshots and a stable label resolver; change resolver identity when + speaker metadata or locale changes. Its positional fast path and weak-key + fallback must preserve text, timing, revision, speaker and locale updates. + Tests bound row formatting and new display objects; benchmark CPU timings + remain informational and include the initial projection cost. +- Debug Console keeps virtualizer key callbacks and level counts stable for + unchanged log snapshots. Memoized log messages receive stable copy callbacks + and row-local feedback; serialize raw structured JSON only when opened. - Paginated transcript endpoints and virtualized history lists. +- Shared history virtualizers and the Live Mic/YouTube/File callers keep stable + item-key functions; scrolling must not reconstruct every loaded row's key. + Completed summary renderers skip unrelated UI updates but invalidate when + content or locale changes. Intl formatter caches are bounded to 32 instances + per kind, retain no rendered text, and reset date formatters on window focus. +- Idle settings/device, active-Meeting, and history preloads are independent; + keep history requests sequential/frame-yielded, forward query abort signals, + and suppress late settings publication after cancellation. +- Transcript history indexes include the ID tie-breaker and status for ordered, + covering reads. Migrate only their app-owned definitions once. Punctuation + search reuses FTS visible-summary text with a fallback for missing/mismatched + index rows; preserve literal substring semantics and hidden-HTML exclusion. +- Metadata-only transcript saves compare the real indexed projection under the + parent write transaction and skip unchanged FTS rewrites. Preserve missing/ + stale-row repair, summary-format semantics and rollback on projection errors. +- Upload patches that change no field keep snapshot identity and do not notify + subscribers. Preserve the server-processing transition after rounded 100%. + File history partitions depend on history data, not upload or copy UI state. - Meeting detail assembly validates existence once and reuses its SQLite connection for related collections instead of repeating helper lookups. - The native 10-ms Meeting Mic/System/AEC relay reuses decode, clean-output, @@ -2159,6 +2256,23 @@ Already implemented and should not be regressed: lease, applies duration-scaled provider budgets, and deduplicates cross-track echo with a timeline sweep instead of all mic-by-system pairs. - Coalesced `history_updated` events. +- Live Meeting `meeting_segment` merges are linear: one scan plus binary-search + insertion, with the stable full sort only for unsorted input or comparator + ties. `meeting-segment-merge.test.ts` proves equivalence with + replace/append-then-sort and holds a deterministic `startMs`-read ceiling + for a 3,000-event stream; lower that ceiling when work drops, never raise it + to admit a regression. Measured 2026-10-05 on 6,000 segments: about 2.3x less + merge time than the former per-event copy-and-sort. +- Meeting library rows prefetch detail after 150 ms of sustained pointer or + keyboard-focus intent, with the query's own 30-second stale time. Keep one + speculative request active, cancel unused owned requests on leave, and retain + requests adopted by navigation or observed elsewhere. Never cancel an existing + consumer's request or prefetch the selected Meeting or one pending discard. + A speculative response must retain cache updates received while it was in + flight, including live segments and edits, instead of restoring its stale snapshot. + Review matching uses one pass; build the membership set only for active + filters and reuse visible rows for timeline matches. Unfiltered previous/next + navigation still needs the ordered IDs. - Chunked/offloaded upload writes and export/cleanup work where practical. - JobStore and latency metrics store connection reuse. - App-owned aiohttp provider connection reuse with DNS caching and bounded diff --git a/Frontend/benchmarks/formatters.ts b/Frontend/benchmarks/formatters.ts new file mode 100644 index 00000000..fa67742f --- /dev/null +++ b/Frontend/benchmarks/formatters.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { dateFormatter, numberFormatter } from "../client/src/i18n/formatters"; + +const count = 5_000; +const date = new Date("2026-10-05T12:34:56Z"); +const options = { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" } as const; +function run(cached: boolean): number { + let checksum = 0; + for (let i = 0; i < count; i++) { + const locale = i % 2 ? "en-US" : "de-DE"; + checksum += (cached ? dateFormatter(locale, { ...options }) : new Intl.DateTimeFormat(locale, options)).format( + date, + ).length; + checksum += ( + cached + ? numberFormatter(locale, { maximumFractionDigits: 2 }) + : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }) + ).format(i / 3).length; + } + return checksum; +} +assert.equal(run(false), run(true)); +function median(cached: boolean) { + return Array.from({ length: 5 }, () => { + const start = performance.now(); + run(cached); + return performance.now() - start; + }).sort((a, b) => a - b)[2]; +} +console.log( + JSON.stringify({ node: process.version, rows: count, beforeMs: median(false), afterMs: median(true) }, null, 2), +); diff --git a/Frontend/benchmarks/meeting-display.ts b/Frontend/benchmarks/meeting-display.ts new file mode 100644 index 00000000..f3550920 --- /dev/null +++ b/Frontend/benchmarks/meeting-display.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import type { MeetingSegment } from "../client/src/lib/api-types"; +import { createMeetingSegmentProjector } from "../client/src/lib/meeting-segment-display"; + +const initial: MeetingSegment[] = Array.from({ length: 12_000 }, (_, i) => ({ + id: `s${i}`, + meetingId: "synthetic", + revision: "live", + source: "system", + speakerId: `speaker-${i % 6}`, + speakerLabel: `Speaker ${i % 6}`, + startMs: i * 600, + endMs: i * 600 + 900, + durationMs: 900, + text: "Synthetic benchmark passage", + confidence: null, + alignmentQuality: "provider_segment", + isFinal: true, + sequence: i, + createdAt: "2026-10-06", + editVersion: 0, + editedAt: null, +})); +const snapshots: MeetingSegment[][] = []; +let current = initial; +for (let i = 0; i < 100; i++) { + const index = (i * 7919) % current.length; + current = current.map((segment, position) => + position === index ? { ...segment, text: `Synthetic update ${i}` } : segment, + ); + snapshots.push(current); +} +const labelFor = (segment: MeetingSegment) => segment.speakerLabel; +const reference = (segments: MeetingSegment[]) => segments.map((segment) => ({ ...segment, label: labelFor(segment) })); +const check = createMeetingSegmentProjector(); +let prior = check(initial, labelFor); +let newDisplays = 0; +for (const snapshot of snapshots) { + const result = check(snapshot, labelFor); + assert.deepEqual(result, reference(snapshot)); + newDisplays += result.filter((value, index) => value !== prior[index]).length; + prior = result; +} +assert.equal(newDisplays, snapshots.length); +let checksum = 0; +function measure(setup: () => () => void) { + const values: number[] = []; + for (let i = 0; i < 12; i++) { + const run = setup(); + const start = performance.now(); + run(); + if (i >= 3) values.push(performance.now() - start); + } + return Number(values.sort((a, b) => a - b)[4].toFixed(3)); +} +const referenceMs = measure(() => () => { + for (const snapshot of snapshots) checksum += reference(snapshot).length; +}); +const reusedMs = measure(() => { + const project = createMeetingSegmentProjector(); + project(initial, labelFor); + return () => { + for (const snapshot of snapshots) checksum += project(snapshot, labelFor).length; + }; +}); +const firstReferenceMs = measure(() => () => { + checksum += reference(initial).length; +}); +const firstProjectionMs = measure(() => { + const project = createMeetingSegmentProjector(); + return () => { + checksum += project(initial, labelFor).length; + }; +}); +console.log( + JSON.stringify( + { + node: process.version, + segments: initial.length, + updates: snapshots.length, + referenceNewDisplays: initial.length * snapshots.length, + reusedNewDisplays: newDisplays, + referenceMs, + reusedMs, + firstProjectionMs, + firstReferenceMs, + medianOf: 9, + checksum, + }, + null, + 2, + ), +); diff --git a/Frontend/benchmarks/meeting-review.ts b/Frontend/benchmarks/meeting-review.ts new file mode 100644 index 00000000..24dad316 --- /dev/null +++ b/Frontend/benchmarks/meeting-review.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { + activeReviewSegmentId, + createReviewPlaybackLookup, + type ReviewTimelineSegment, +} from "../client/src/lib/meeting-review-timeline"; + +// Synthetic two-hour timeline, with overlapping microphone/system turns. +// No transcript content, network, credentials, or installed app required. +const segments: ReviewTimelineSegment[] = Array.from({ length: 12_000 }, (_, i) => ({ + id: `segment-${i}`, + revision: "canonical", + speakerId: null, + label: "Speaker", + startMs: i * 600, + endMs: i * 600 + 900, + alignmentQuality: i % 3 ? "provider_segment" : "exact_word", + text: "Synthetic benchmark text", +})); +const samples = Array.from({ length: 2_000 }, (_, i) => (i * 7919) % 7_200_000); +const lookup = createReviewPlaybackLookup(segments); +for (const atMs of samples) assert.equal(lookup(atMs), activeReviewSegmentId(segments, atMs)); + +function measure(run: () => void) { + for (let i = 0; i < 3; i++) run(); + const timings = Array.from({ length: 9 }, () => { + const started = performance.now(); + run(); + return performance.now() - started; + }).sort((a, b) => a - b); + return Number(timings[4].toFixed(3)); +} + +let checksum = 0; +const linearMs = measure(() => { + for (const atMs of samples) checksum += activeReviewSegmentId(segments, atMs)?.length ?? 0; +}); +const indexedMs = measure(() => { + for (const atMs of samples) checksum += lookup(atMs)?.length ?? 0; +}); +const buildMs = measure(() => { + checksum += createReviewPlaybackLookup(segments)(600)?.length ?? 0; +}); +console.log( + JSON.stringify( + { + node: process.version, + segments: segments.length, + lookups: samples.length, + medianOf: 9, + linearMs, + indexedMs, + buildMs, + speedup: Number((linearMs / indexedMs).toFixed(2)), + checksum, + }, + null, + 2, + ), +); diff --git a/Frontend/client/src/App.tsx b/Frontend/client/src/App.tsx index 349aacd2..55fa1dc5 100644 --- a/Frontend/client/src/App.tsx +++ b/Frontend/client/src/App.tsx @@ -14,8 +14,8 @@ import { recordingErrorToastMessageFromPayload, showRecordingErrorToast } from " import { showPostProcessingFallbackToast, type CredentialSettingsRequest } from "@/lib/post-processing-fallback-toast"; import { useToast } from "@/hooks/use-toast"; import { lazy, Suspense, useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; -import { isTauriRuntime, loadBackendBaseUrlFromTauri, setTrayRecordingState } from "@/lib/backend"; -import { withPromiseTimeout } from "@/lib/fetch-with-timeout"; +import { isTauriRuntime, setTrayRecordingState } from "@/lib/backend"; +import { isInitialBackendAccessReady, loadInitialBackendAccess } from "@/lib/initial-backend-access"; import { preloadPrimaryTabData } from "@/lib/tab-data-preload"; import { flushFrontendPerformanceReport, setFrontendPerformanceReportingEnabled } from "@/lib/frontend-performance"; import { ToastAction } from "@/components/ui/toast"; @@ -610,19 +610,13 @@ function FrontendPerformanceFlushBridge() { } function App() { - const [backendBaseReady, setBackendBaseReady] = useState(!isTauriRuntime()); + const [backendBaseReady, setBackendBaseReady] = useState(isInitialBackendAccessReady); useEffect(() => { let cancelled = false; - void withPromiseTimeout(loadBackendBaseUrlFromTauri(), 5_000, "Initial Tauri backend lookup") - .catch((error) => { - console.debug("Initial Tauri backend lookup failed; continuing with health fallback.", error); - }) - .finally(() => { - if (!cancelled) { - setBackendBaseReady(true); - } - }); + void loadInitialBackendAccess().then(() => { + if (!cancelled) setBackendBaseReady(true); + }); return () => { cancelled = true; }; diff --git a/Frontend/client/src/components/TrayPanel.test.tsx b/Frontend/client/src/components/TrayPanel.test.tsx index bca1b255..106c732e 100644 --- a/Frontend/client/src/components/TrayPanel.test.tsx +++ b/Frontend/client/src/components/TrayPanel.test.tsx @@ -8,6 +8,11 @@ const mocks = vi.hoisted(() => ({ fetch: vi.fn(), action: vi.fn(), hide: vi.fn(), + access: vi.fn(), + status: vi.fn(), + hotkeys: vi.fn(), + refreshHotkeys: vi.fn(), + listenSetup: vi.fn(), cleanups: [] as string[], listeners: new Map void>(), ready: { @@ -22,16 +27,21 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/backend", () => ({ isTauriRuntime: () => true, loadBackendBaseUrlFromTauri: async () => undefined, - getTrayStatus: async () => mocks.ready, - getGlobalHotkeyStatus: async () => ({ hotkey: "Ctrl+F9" }), - refreshGlobalHotkey: async () => ({ hotkey: "Ctrl+F9" }), + getTrayStatus: mocks.status, + getGlobalHotkeyStatus: mocks.hotkeys, + refreshGlobalHotkey: mocks.refreshHotkeys, hideTrayPanel: mocks.hide, trayAction: mocks.action, apiUrl: (path: string) => path, })); +vi.mock("@/lib/initial-backend-access", () => ({ + isInitialBackendAccessReady: () => false, + loadInitialBackendAccess: mocks.access, +})); vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "0.5.0" })); vi.mock("@tauri-apps/api/event", () => ({ listen: async (event: string, listener: (event: { payload: TrayStatus }) => void) => { + await mocks.listenSetup(event); mocks.listeners.set(event, listener); return () => { mocks.listeners.delete(event); @@ -65,6 +75,11 @@ function opened() { } beforeEach(() => { + mocks.listenSetup.mockReset().mockResolvedValue(undefined); + mocks.access.mockReset().mockResolvedValue(undefined); + mocks.status.mockReset().mockResolvedValue(mocks.ready); + mocks.hotkeys.mockReset().mockResolvedValue({ hotkey: "Ctrl+F9" }); + mocks.refreshHotkeys.mockReset().mockResolvedValue({ hotkey: "Ctrl+F9" }); mocks.listeners.clear(); mocks.cleanups.length = 0; mocks.fetch.mockReset().mockResolvedValue(response()); @@ -136,6 +151,8 @@ it("discards older responses and errors after a newer opening even when abort is try { await waitFor(() => expect(pending.length).toBe(1)); fireEvent.click(view.getByRole("button", { name: /Recent Transcripts/ })); + expect(pending.length).toBe(1); + await act(async () => opened()); await waitFor(() => expect(pending.length).toBe(2)); await act(async () => opened()); await act(async () => pending[2](response([item("new", "Newest text")]))); @@ -290,3 +307,145 @@ it("aborts pending history reads on unmount and does not leave an opened listene await act(async () => resolve(response([item("late", "Late answer")]))); expect(mocks.hide).not.toHaveBeenCalled(); }); + +it("does not query or re-register hotkeys for recording/update status bursts", async () => { + const { default: TrayPanel } = await import("./TrayPanel"); + const view = render(); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + mocks.hotkeys.mockClear(); + mocks.refreshHotkeys.mockClear(); + await act(async () => { + for (let index = 0; index < 100; index++) { + mocks.listeners.get("scriber-tray-status")?.({ payload: { ...mocks.ready, recordingActive: index % 2 === 0 } }); + } + }); + expect(mocks.hotkeys).not.toHaveBeenCalled(); + expect(mocks.refreshHotkeys).not.toHaveBeenCalled(); + mocks.hotkeys.mockResolvedValue({ hotkey: "Ctrl+F10", meetingHotkey: "Ctrl+F11" }); + await act(async () => opened()); + await view.findByText("Ctrl+F10"); + await view.findByText("Ctrl+F11"); + expect(mocks.hotkeys).toHaveBeenCalledTimes(1); +}); + +it("subscribes once before the status read and preserves a newer event across access readiness", async () => { + let finishStatus!: (status: TrayStatus) => void; + let finishAccess!: () => void; + mocks.access.mockReturnValue( + new Promise((resolve) => { + finishAccess = resolve; + }), + ); + mocks.status.mockImplementation(() => { + expect(mocks.listeners.has("scriber-tray-status")).toBe(true); + return new Promise((resolve) => { + finishStatus = resolve; + }); + }); + const { default: TrayPanel } = await import("./TrayPanel"); + const view = render(); + await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(1)); + await act(async () => + mocks.listeners.get("scriber-tray-status")?.({ payload: { ...mocks.ready, recordingActive: true } }), + ); + await act(async () => { + finishStatus(mocks.ready); + finishAccess(); + }); + await view.findByRole("button", { name: /Stop Recording/ }); + expect(mocks.status).toHaveBeenCalledTimes(1); + expect(mocks.cleanups).not.toContain("scriber-tray-status"); +}); + +it("reuses an opening's pending history read when entering Recent Transcripts", async () => { + let finish!: (value: ReturnType) => void; + mocks.fetch.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const { default: TrayPanel } = await import("./TrayPanel"); + const view = render(); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + const signal = mocks.fetch.mock.calls[0][1].signal as AbortSignal; + fireEvent.click(view.getByRole("button", { name: /Recent Transcripts/ })); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect(signal.aborted).toBe(false); + await act(async () => finish(response([item("fresh", "Fresh text")]))); + fireEvent.click(await view.findByRole("button", { name: /Fresh text/ })); + await waitFor(() => expect(mocks.action).toHaveBeenCalledWith("copy_transcript:fresh")); +}); + +it("falls back to a status read when listener registration fails", async () => { + vi.spyOn(console, "debug").mockImplementation(() => undefined); + mocks.listenSetup.mockImplementation(async (event) => { + if (event === "scriber-tray-status") throw new Error("listener unavailable"); + }); + mocks.status.mockResolvedValue({ ...mocks.ready, recordingActive: true }); + const { default: TrayPanel } = await import("./TrayPanel"); + const view = render(); + await view.findByRole("button", { name: /Stop Recording/ }); + expect(mocks.status).toHaveBeenCalledTimes(1); +}); + +it("loads shortcuts and history even when the opened listener fails", async () => { + mocks.listenSetup.mockImplementation(async (event) => { + if (event === "scriber-tray-opened") throw new Error("listener unavailable"); + }); + mocks.fetch.mockResolvedValue(response([item("fresh", "Fresh text")])); + const { default: TrayPanel } = await import("./TrayPanel"); + const view = render(); + await view.findByText(/Ctrl.*F9/); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + fireEvent.click(view.getByRole("button", { name: /Recent Transcripts/ })); + await view.findByRole("button", { name: /Fresh text/ }); + expect(mocks.hotkeys).toHaveBeenCalledTimes(1); +}); + +it("bounds a stalled opened listener and cleans it up without a late refresh", async () => { + let finishListener!: () => void; + mocks.listenSetup.mockImplementation((event) => + event === "scriber-tray-opened" + ? new Promise((resolve) => { + finishListener = resolve; + }) + : Promise.resolve(), + ); + const { default: TrayPanel } = await import("./TrayPanel"); + vi.useFakeTimers(); + const view = render(); + await act(async () => {}); + await act(async () => vi.advanceTimersByTimeAsync(1999)); + expect(mocks.hotkeys).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.hotkeys).toHaveBeenCalledTimes(1); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + view.unmount(); + await act(async () => finishListener()); + expect(mocks.cleanups).toContain("scriber-tray-opened"); + expect(mocks.hotkeys).toHaveBeenCalledTimes(1); + expect(mocks.fetch).toHaveBeenCalledTimes(1); +}); + +it("bounds stalled listener setup and cleans a listener arriving after unmount", async () => { + let finishListener!: () => void; + mocks.listenSetup.mockImplementation((event) => + event === "scriber-tray-status" + ? new Promise((resolve) => { + finishListener = resolve; + }) + : Promise.resolve(), + ); + const { default: TrayPanel } = await import("./TrayPanel"); + vi.useFakeTimers(); + const view = render(); + await act(async () => vi.advanceTimersByTimeAsync(1999)); + expect(mocks.status).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.status).toHaveBeenCalledTimes(1); + view.unmount(); + await act(async () => finishListener()); + expect(mocks.cleanups).toContain("scriber-tray-status"); + expect(mocks.listeners.size).toBe(0); + expect(mocks.status).toHaveBeenCalledTimes(1); +}); diff --git a/Frontend/client/src/components/TrayPanel.tsx b/Frontend/client/src/components/TrayPanel.tsx index 57dfa6a4..4fc5d02a 100644 --- a/Frontend/client/src/components/TrayPanel.tsx +++ b/Frontend/client/src/components/TrayPanel.tsx @@ -29,13 +29,13 @@ import { getTrayStatus, hideTrayPanel, isTauriRuntime, - loadBackendBaseUrlFromTauri, refreshGlobalHotkey, trayAction, type TrayStatus, } from "@/lib/backend"; import type { TrayTranscriptItem, TranscriptType } from "@/lib/api-types"; import { checkDesktopUpdate, installDesktopUpdate, type DesktopUpdateProgress } from "@/lib/desktop-updates"; +import { isInitialBackendAccessReady, loadInitialBackendAccess } from "@/lib/initial-backend-access"; import { cn } from "@/lib/utils"; import { fetchWithTimeout } from "@/lib/fetch-with-timeout"; import { useI18n, type TranslationValues } from "@/i18n"; @@ -164,7 +164,7 @@ function TrayRow({ onClick={disabled ? undefined : onClick} disabled={disabled} className={cn( - "group flex h-[42px] w-full items-center gap-3 rounded-[12px] px-3 text-left outline-none transition-colors duration-150", + "tray-action-row group flex h-[42px] w-full items-center gap-3 rounded-[12px] px-3 text-left outline-none transition-colors duration-150", "focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white/80", variant === "default" && "text-slate-950 hover:bg-slate-950/[0.055]", variant === "primary" && "bg-blue-50 text-blue-700 hover:bg-blue-100", @@ -195,7 +195,7 @@ function TrayRow({ {detail ? ( @@ -237,7 +237,7 @@ function RecentTranscriptRow({ onClick={onCopy} disabled={disabled} className={cn( - "group flex h-[46px] w-full items-center gap-3 rounded-[12px] px-3 text-left outline-none transition-colors duration-150", + "tray-recent-row group flex h-[46px] w-full items-center gap-3 rounded-[12px] px-3 text-left outline-none transition-colors duration-150", "text-slate-950 hover:bg-slate-950/[0.055]", "focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white/80", copied && "bg-emerald-50 text-emerald-700", @@ -290,7 +290,7 @@ function formatShortcut(raw: string | undefined, t: Translate): string { export default function TrayPanel() { const { formatNumber, t } = useI18n(); - const [backendReady, setBackendReady] = useState(!isTauriRuntime()); + const [backendReady, setBackendReady] = useState(isInitialBackendAccessReady); const [view, setView] = useState("main"); const [status, setStatus] = useState(DEFAULT_TRAY_STATUS); const [appVersion, setAppVersion] = useState(""); @@ -322,24 +322,21 @@ export default function TrayPanel() { [t], ); - const loadRegisteredShortcuts = useCallback( - async (refreshRegistration: boolean) => { - if (!isTauriRuntime() || !backendReady) return; - const requestId = ++shortcutLoadRequestRef.current; - try { - let value = refreshRegistration ? await refreshGlobalHotkey() : await getGlobalHotkeyStatus(); - if (!value?.hotkey && !refreshRegistration) { - value = await refreshGlobalHotkey(); - } - if (requestId === shortcutLoadRequestRef.current) { - applyShortcuts(value?.hotkey, value?.meetingHotkey); - } - } catch (error) { - console.debug("Tray hotkey lookup failed.", error); + const loadRegisteredShortcuts = useCallback(async () => { + if (!isTauriRuntime() || !backendReady) return; + const requestId = ++shortcutLoadRequestRef.current; + try { + let value = await getGlobalHotkeyStatus(); + if (!value?.hotkey) { + value = await refreshGlobalHotkey(); } - }, - [applyShortcuts, backendReady], - ); + if (requestId === shortcutLoadRequestRef.current) { + applyShortcuts(value?.hotkey, value?.meetingHotkey); + } + } catch (error) { + console.debug("Tray hotkey lookup failed.", error); + } + }, [applyShortcuts, backendReady]); useEffect(() => { document.documentElement.dataset.scriberTrayWindow = "true"; @@ -366,7 +363,7 @@ export default function TrayPanel() { useEffect(() => { let cancelled = false; - void loadBackendBaseUrlFromTauri().finally(() => { + void loadInitialBackendAccess().then(() => { if (!cancelled) { setBackendReady(true); } @@ -380,32 +377,57 @@ export default function TrayPanel() { if (!isTauriRuntime()) return; let unlisten: (() => void) | undefined; let disposed = false; - void getTrayStatus() - .then((value) => { - if (value) setStatus(value); - }) - .catch((error) => console.debug("Tray status lookup failed.", error)); + let revision = 0; + const publish = (value: TrayStatus) => { + const next = { ...DEFAULT_TRAY_STATUS, ...value }; + setStatus((previous) => + previous.recordingActive === next.recordingActive && + previous.recordingMode === next.recordingMode && + previous.updateAvailable === next.updateAvailable && + previous.updateInstalling === next.updateInstalling && + previous.updateVersion === next.updateVersion && + previous.updateMessage === next.updateMessage + ? previous + : next, + ); + }; + const readStatus = () => { + clearTimeout(fallbackTimer); + if (disposed) return; + const readRevision = ++revision; + void getTrayStatus() + .then((value) => { + if (!disposed && revision === readRevision && value) publish(value); + }) + .catch((error) => console.debug("Tray status lookup failed.", error)); + }; + // A failed or stalled listener must not prevent the initial status read. + const fallbackTimer = setTimeout(readStatus, 2_000); + // Subscribe before reading. A newer event must win over a slow initial + // snapshot, and access readiness must not reinstall this subscription. void listen("scriber-tray-status", (event) => { - setStatus({ ...DEFAULT_TRAY_STATUS, ...event.payload }); - void loadRegisteredShortcuts(false); + if (disposed) return; + revision += 1; + publish(event.payload); }) .then((cleanup) => { if (disposed) { cleanup(); - } else { - unlisten = cleanup; + return; } + unlisten = cleanup; + readStatus(); }) - .catch((error) => console.debug("Tray status listener failed.", error)); + .catch((error) => { + console.debug("Tray status listener failed.", error); + readStatus(); + }); return () => { disposed = true; + clearTimeout(fallbackTimer); unlisten?.(); }; - }, [loadRegisteredShortcuts]); - - useEffect(() => { - void loadRegisteredShortcuts(true); - }, [loadRegisteredShortcuts]); + }, []); useEffect(() => { if (!isTauriRuntime()) return; @@ -452,68 +474,82 @@ export default function TrayPanel() { clearTimeout(hideTimerRef.current); }, []); - const loadRecentTranscripts = useCallback(async () => { - if (!backendReady) return; - const requestId = ++recentRequestRef.current; - recentAbortRef.current?.abort(); - const controller = new AbortController(); - recentAbortRef.current = controller; - clearTimeout(hideTimerRef.current); - setCopiedTranscriptId(""); - setRecentItems([]); - setRecentLoading(true); - setRecentError(""); - try { - const response = await fetchWithTimeout( - apiUrl("/api/transcripts/recent"), - { - credentials: "include", - cache: "no-store", - signal: controller.signal, - }, - 10_000, - ); - if (!response.ok) { - throw new Error(t("Could not load recent transcripts ({{status}}).", { status: response.status })); + const loadRecentTranscripts = useCallback( + async ({ reusePending = false }: { reusePending?: boolean } = {}) => { + if (!backendReady) return; + // Entering the recent view can reuse this opening's pending read. A new + // native opening or explicit refresh still invalidates older requests. + if (reusePending && recentAbortRef.current && !recentAbortRef.current.signal.aborted) return; + const requestId = ++recentRequestRef.current; + recentAbortRef.current?.abort(); + const controller = new AbortController(); + recentAbortRef.current = controller; + clearTimeout(hideTimerRef.current); + setCopiedTranscriptId(""); + setRecentItems([]); + setRecentLoading(true); + setRecentError(""); + try { + const response = await fetchWithTimeout( + apiUrl("/api/transcripts/recent"), + { + credentials: "include", + cache: "no-store", + signal: controller.signal, + }, + 10_000, + ); + if (!response.ok) { + throw new Error(t("Could not load recent transcripts ({{status}}).", { status: response.status })); + } + const payload = (await response.json()) as TranscriptListResponse; + if (requestId !== recentRequestRef.current) return; + if (!Array.isArray(payload.items)) throw new Error(t("Could not load recent transcripts.")); + const seen = new Set(); + setRecentItems( + payload.items + .filter((item) => { + if ( + !item || + item.status !== "completed" || + typeof item.id !== "string" || + !/^[A-Za-z0-9_-]{1,160}$/.test(item.id) || + seen.has(item.id) + ) + return false; + seen.add(item.id); + return true; + }) + .slice(0, RECENT_TRANSCRIPT_LIMIT), + ); + setRecentLoaded(true); + } catch { + if (requestId === recentRequestRef.current) setRecentError(t("Could not load recent transcripts.")); + } finally { + if (requestId === recentRequestRef.current) { + recentAbortRef.current = null; + setRecentLoading(false); + } } - const payload = (await response.json()) as TranscriptListResponse; - if (requestId !== recentRequestRef.current) return; - if (!Array.isArray(payload.items)) throw new Error(t("Could not load recent transcripts.")); - const seen = new Set(); - setRecentItems( - payload.items - .filter((item) => { - if ( - !item || - item.status !== "completed" || - typeof item.id !== "string" || - !/^[A-Za-z0-9_-]{1,160}$/.test(item.id) || - seen.has(item.id) - ) - return false; - seen.add(item.id); - return true; - }) - .slice(0, RECENT_TRANSCRIPT_LIMIT), - ); - setRecentLoaded(true); - } catch { - if (requestId === recentRequestRef.current) setRecentError(t("Could not load recent transcripts.")); - } finally { - if (requestId === recentRequestRef.current) setRecentLoading(false); - } - }, [backendReady, t]); + }, + [backendReady, t], + ); useEffect(() => { if (!backendReady) return; let disposed = false; let unlisten: (() => void) | undefined; + let fallbackTimer: ReturnType | undefined; const refresh = () => { + clearTimeout(fallbackTimer); + if (disposed) return; clearTimeout(blurTimerRef.current); setError(""); + void loadRegisteredShortcuts(); void loadRecentTranscripts(); }; if (isTauriRuntime()) { + fallbackTimer = setTimeout(refresh, 2_000); // Listen before the initial read: the first show can precede WebView // startup, while every subsequent show (even already focused) emits this. void listen("scriber-tray-opened", refresh) @@ -525,22 +561,24 @@ export default function TrayPanel() { } }) .catch(() => { - if (!disposed) setRecentError(t("Could not load recent transcripts.")); + refresh(); }); } else refresh(); return () => { disposed = true; + clearTimeout(fallbackTimer); unlisten?.(); + shortcutLoadRequestRef.current += 1; cancelRecentRequest(); }; - }, [backendReady, cancelRecentRequest, loadRecentTranscripts, t]); + }, [backendReady, cancelRecentRequest, loadRecentTranscripts, loadRegisteredShortcuts, t]); const openRecentView = useCallback(() => { setError(""); setRecentError(""); setCopiedTranscriptId(""); setView("recent"); - void loadRecentTranscripts(); + void loadRecentTranscripts({ reusePending: true }); }, [loadRecentTranscripts]); const copyRecentTranscript = useCallback( @@ -645,14 +683,14 @@ export default function TrayPanel() { })(); return ( -
+
-
+
@@ -677,7 +715,7 @@ export default function TrayPanel() {
{view === "main" && showUpdateInstallBanner ? ( -
+
) : null} -
+
{view === "main" ? ( -
+
) : ( -
+
+ ))} +
+ )} +
+ )} + + {isRecording ? ( +
+ +
+ ) : null} +
+
+ + ); +}); + +export default function LiveMic() { + const { toast } = useToast(); + const { t } = useI18n(); + const [deletingId, setDeletingId] = useState(null); + const [copyingId, setCopyingId] = useState(null); + const deletingRef = useRef(null); + const copyingRef = useRef(null); + const copyResetTimerRef = useRef(null); + const [, setLocation] = useLocation(); + const queryClient = useQueryClient(); + const { + debouncedSearch, + searchValue: searchQuery, + setSearchValue: setSearchQuery, + setViewMode, + viewMode, + } = useTranscriptHistoryPanelState({ defaultViewMode: "grid" }); + const transcriptsQueryKey = useMemo(() => transcriptHistoryQueryKey("mic", debouncedSearch), [debouncedSearch]); + const { refreshNow: refreshMicHistory } = useTranscriptAutoRefresh({ queryKey: transcriptsQueryKey }); + + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + }, + [], + ); + + const transcriptsQuery = useTranscriptHistoryQuery({ type: "mic", q: debouncedSearch }); + const transcripts = transcriptsQuery.items; + const historyLocalDay = new Date().toDateString(); + const historyReferenceTime = useMemo(() => { + void historyLocalDay; + return new Date(); + }, [historyLocalDay]); + const getTranscriptHistoryGroup = useCallback( + (item: Transcript) => transcriptHistoryPeriod(item.createdAt, historyReferenceTime), + [historyReferenceTime], + ); const deleteTranscript = useCallback( async (e: React.MouseEvent, id: string) => { @@ -1048,39 +1257,21 @@ export default function LiveMic() { [queryClient], ); - const stageStatusLabel = - toggleAction === "start" - ? t("Starting") - : toggleAction === "stop" - ? t("Stopping") - : micStartPending - ? t("Preparing next recording") - : isRecording - ? t("Listening") - : isPreparing - ? t("Preparing microphone") - : isTranscribing - ? t("Transcribing") - : isConnected - ? t("Ready") - : t("Offline"); - - const stageStatusHint = - toggleAction === "start" - ? t("Connecting to your input device") - : toggleAction === "stop" - ? t("Saving your recording") - : micStartPending - ? t("Starts as soon as the microphone is ready") - : isRecording - ? t("Use the microphone button to stop") - : isPreparing - ? t("Connecting to your input device") - : isTranscribing - ? t("You can start your next recording now") - : isConnected - ? t("Use the microphone button to start") - : t("Reconnecting to Scriber"); + const renderHistoryItem = useCallback( + (item: Transcript) => ( + + ), + [copyingId, deleteTranscript, deletingId, copyTranscript, navigateToTranscript, preloadTranscript, viewMode], + ); return (
@@ -1092,170 +1283,7 @@ export default function LiveMic() { />
-
-
-
-
-
- -
- {/* Controls */} - - -
-

{stageStatusHint}

-

- {formatTime(elapsed)} -

-
-
-
- -
-
-
-

- {!hasActiveSession && finalText ? t("Last transcript") : t("Live transcript")} -

-

- {!hasActiveSession && finalText - ? t("Saved to Recent recordings.") - : t("Speech appears here while you record.")} -

-
- {(micStartPending || isPreparing || isTranscribing) && ( - - - {stageStatusLabel} - - )} -
- - {/* Live Text Output - Debossed status well for unified design */} -
-

- {micStartPending - ? t("Next recording queued. Starts as soon as the microphone is ready.") - : isRecording - ? t("Recording started.") - : isPreparing - ? t("Preparing microphone.") - : isTranscribing - ? t("Transcribing recording.") - : t("Recording stopped.")} -

-
- {isRecording || finalText || interimText ? ( -

- {finalText || interimText ? ( - <> - {finalText} - {interimText && ( - - {finalText ? " " : ""} - {interimText} - - )} - - ) : isRecording ? ( - {localizedLiveStatus(status, "Listening", t)}… - ) : ( - {t("No speech was detected.")} - )} -

- ) : isPreparing || isTranscribing ? ( -

- {localizedLiveStatus(status, isTranscribing ? "Transcribing..." : "Preparing microphone...", t)} -

- ) : ( -
-

- {t("Your live transcript will appear here.")} -

-

- {t("Start recording with the microphone button or your global hotkey.")} -

-
- )} -
-
- - {isRecording && inputWarning && ( -
-

{t(inputWarning)}

- {inputWarningActions.length > 0 && ( -
- {inputWarningActions.map((action) => ( - - ))} -
- )} -
- )} - - {isRecording ? ( -
- -
- ) : null} -
-
-
+ {/* History Section */}
@@ -1292,25 +1320,14 @@ export default function LiveMic() { item.id} + getItemKey={transcriptHistoryItemKey} getItemGroup={getTranscriptHistoryGroup} estimateListRowHeight={108} estimateGridRowHeight={230} hasMore={transcriptsQuery.hasNextPage} isLoadingMore={transcriptsQuery.isFetchingNextPage} - onLoadMore={() => transcriptsQuery.fetchNextPage()} - renderItem={(item) => ( - - )} + onLoadMore={transcriptsQuery.fetchNextPage} + renderItem={renderHistoryItem} />
diff --git a/Frontend/client/src/pages/Meetings.test.tsx b/Frontend/client/src/pages/Meetings.test.tsx new file mode 100644 index 00000000..65645c3d --- /dev/null +++ b/Frontend/client/src/pages/Meetings.test.tsx @@ -0,0 +1,433 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { LocaleProvider, LANGUAGE_STORAGE_KEY } from "@/i18n"; +import type { MeetingDetail, MeetingSegment } from "@/lib/api-types"; +import { MEETING_HISTORY_QUERY_KEY } from "@/lib/meeting-cache"; +import { createReviewPlaybackLookup } from "@/lib/meeting-review-timeline"; +import Meetings from "./Meetings"; + +const observed = vi.hoisted(() => ({ + transcriptRenders: 0, + rowFormats: 0, + i18nReads: 0, + itemKeys: [] as Array<(index: number) => string | number>, + scrollToIndex: vi.fn(), + apiRequest: vi.fn(), +})); +vi.mock("@/i18n", async (importOriginal) => { + const actual = await importOriginal(); + type Formatter = ReturnType["formatNumber"]; + const formatters = new WeakMap(); + return { + ...actual, + useI18n: () => { + // Every rendered Meetings component that shows text reads translations. + observed.i18nReads++; + const context = actual.useI18n(); + let formatNumber = formatters.get(context.formatNumber); + if (!formatNumber) { + formatNumber = (value, options) => { + if (options?.minimumFractionDigits === 1 && options.maximumFractionDigits === 1) observed.rowFormats++; + return context.formatNumber(value, options); + }; + formatters.set(context.formatNumber, formatNumber); + } + return { ...context, formatNumber }; + }, + }; +}); +vi.mock("@/contexts/WebSocketContext", () => ({ + useWebSocketContext: () => ({ isConnected: true }), + useSharedWebSocket: () => ({ isConnected: true }), +})); +vi.mock("@/lib/queryClient", async (importOriginal) => ({ + ...(await importOriginal()), + apiRequest: observed.apiRequest, +})); +vi.mock("@/components/meeting/SpeakerAttendeeAssignments", () => ({ SpeakerAttendeeAssignments: () => null })); +vi.mock("@/lib/meeting-review-timeline", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createReviewPlaybackLookup: vi.fn(actual.createReviewPlaybackLookup) }; +}); +// JSDOM has no viewport layout. Keep the real page, memo boundary, row markup, +// effects and interactions; substitute only the virtualizer's geometry. +vi.mock("@tanstack/react-virtual", () => { + const virtualizer = { + getTotalSize: () => 264, + getVirtualItems: () => [0, 1, 2].map((index) => ({ index, start: index * 88 })), + measureElement: () => undefined, + scrollToIndex: observed.scrollToIndex, + }; + return { + useVirtualizer: (options: { getItemKey: (index: number) => string | number }) => { + observed.transcriptRenders++; + observed.itemKeys.push(options.getItemKey); + return virtualizer; + }, + }; +}); + +function meeting(id = "meeting-1"): MeetingDetail { + const createdAt = "2026-10-01T12:00:00Z"; + const segments: MeetingSegment[] = Array.from({ length: 3 }, (_, i) => ({ + id: `segment-${i}`, + meetingId: id, + revision: "canonical", + source: "system", + speakerId: null, + speakerLabel: "Speaker A", + startMs: i * 10_000, + endMs: i * 10_000 + 9_000, + durationMs: 9_000, + text: `Review passage ${i}`, + confidence: null, + alignmentQuality: "exact_word", + isFinal: true, + sequence: i, + createdAt, + editVersion: 0, + editedAt: null, + })); + return { + apiVersion: "1", + id, + title: `Review ${id}`, + state: "ready", + language: "en", + transcriptionMode: "final_only", + liveProvider: "", + finalProvider: "test", + analysisModel: "", + aecEnabled: false, + voiceLibraryEnabled: false, + consentConfirmed: true, + origin: "captured", + startedAt: createdAt, + endedAt: createdAt, + createdAt, + updatedAt: createdAt, + errorCode: "", + errorMessage: "", + captureMetadata: {}, + audioRetentionDays: 30, + smartTurnEnabled: false, + autoAnalyze: false, + transcriptEditVersion: 0, + processingProgress: null, + segments, + speakers: [], + notes: [], + actionItems: [], + outputs: [], + outputVersions: [], + audioGaps: [], + transcriptCheckpoints: [], + audioAssets: [ + { + id: "mix", + meetingId: id, + kind: "playback_mix", + relativePath: "mix.wav", + codec: "pcm", + sampleRate: 16_000, + channels: 1, + durationMs: 30_000, + byteSize: 1, + sha256: "", + createdAt, + }, + ], + }; +} + +let client: QueryClient; +beforeEach(() => { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "en"); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + observed.transcriptRenders = 0; + observed.itemKeys = []; + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); + client = new QueryClient({ + defaultOptions: { queries: { enabled: false, retry: false }, mutations: { retry: false } }, + }); +}); +afterEach(() => { + client.clear(); + vi.unstubAllGlobals(); +}); + +function renderMeeting(detail = meeting()) { + client.setQueryData(["/api/meetings", detail.id], detail); + client.setQueryData(["/api/meetings", detail.id, "deliveries"], { items: [] }); + const view = render( + + + + + , + ); + return { ...view, audio: view.container.querySelector("audio")! }; +} + +it("timeupdates within one segment do not rerender transcript rows; seeks still update and follow", () => { + const { audio } = renderMeeting(); + expect(audio).not.toBeNull(); + const initialRenders = observed.transcriptRenders; + const itemKey = observed.itemKeys.at(-1); + observed.scrollToIndex.mockClear(); + for (let i = 1; i <= 20; i++) { + audio.currentTime = i / 10; + fireEvent.timeUpdate(audio); + } + expect(observed.transcriptRenders - initialRenders).toBe(0); + expect(createReviewPlaybackLookup).toHaveBeenCalledTimes(1); + expect(observed.scrollToIndex).not.toHaveBeenCalled(); + expect(screen.getByTestId("meeting-transcript-segment-segment-0")).toHaveAttribute("data-playback-active", "true"); + + observed.rowFormats = 0; + audio.currentTime = 12; + fireEvent.timeUpdate(audio); + expect(observed.rowFormats).toBe(2); + expect(observed.transcriptRenders - initialRenders).toBe(1); + expect(observed.itemKeys.at(-1)).toBe(itemKey); + expect(screen.getByTestId("meeting-transcript-segment-segment-1")).toHaveAttribute("data-playback-active", "true"); + expect(observed.scrollToIndex).toHaveBeenCalledWith(1, { align: "center" }); + + fireEvent.click(screen.getByRole("checkbox", { name: "Follow playback" })); + observed.scrollToIndex.mockClear(); + audio.currentTime = 1; + fireEvent.timeUpdate(audio); + expect(screen.getByTestId("meeting-transcript-segment-segment-0")).toHaveAttribute("data-playback-active", "true"); + expect(observed.scrollToIndex).not.toHaveBeenCalled(); +}); + +it("does not construct a playback index when no saved audio is available", () => { + renderMeeting({ ...meeting(), audioAssets: [] }); + expect(createReviewPlaybackLookup).not.toHaveBeenCalled(); + expect(screen.getByText("Review passage 0")).toBeInTheDocument(); +}); + +it("stable edit callbacks save and undo using the latest transcript version after playback", async () => { + const detail = meeting(); + const { audio } = renderMeeting(detail); + audio.currentTime = 1; + fireEvent.timeUpdate(audio); + act(() => { + client.setQueryData(["/api/meetings", detail.id], { ...detail, transcriptEditVersion: 7 }); + }); + await waitFor(() => + expect(client.getQueryData(["/api/meetings", detail.id])?.transcriptEditVersion).toBe(7), + ); + observed.apiRequest.mockResolvedValueOnce( + new Response( + JSON.stringify({ + meetingId: detail.id, + segment: { ...detail.segments[0], text: "Corrected passage", editVersion: 1 }, + transcriptEditVersion: 8, + outputsStale: false, + }), + ), + ); + fireEvent.click(screen.getByTestId("meeting-segment-edit-segment-0")); + fireEvent.change(screen.getByTestId("meeting-segment-edit-input-segment-0"), { + target: { value: "Corrected passage" }, + }); + fireEvent.click(screen.getByTestId("meeting-segment-edit-save-segment-0")); + await waitFor(() => + expect(observed.apiRequest).toHaveBeenCalledWith("PATCH", "/api/meetings/meeting-1/segments/segment-0", { + expectedEditVersion: 7, + text: "Corrected passage", + }), + ); + expect(await screen.findByText("Corrected passage")).toBeInTheDocument(); + observed.apiRequest.mockResolvedValueOnce( + new Response( + JSON.stringify({ + meetingId: detail.id, + segment: detail.segments[0], + transcriptEditVersion: 9, + outputsStale: false, + }), + ), + ); + fireEvent.click(screen.getByTestId("meeting-segment-undo-segment-0")); + await waitFor(() => + expect(observed.apiRequest).toHaveBeenCalledWith("POST", "/api/meetings/meeting-1/segments/segment-0/undo", { + expectedEditVersion: 8, + }), + ); + expect(await screen.findByText("Review passage 0")).toBeInTheDocument(); +}); + +it("Meeting intent prefetch deduplicates pointer/focus and reuses fresh detail data", async () => { + const other = meeting("meeting-2"); + client.setQueryData(MEETING_HISTORY_QUERY_KEY, { + pages: [{ items: [other], total: 1, offset: 0, activeMeeting: null }], + pageParams: [0], + }); + let finish!: (response: Response) => void; + const fetch = vi.fn( + (_input: RequestInfo | URL, _init?: RequestInit) => + new Promise((resolve) => { + finish = resolve; + }), + ); + vi.stubGlobal("fetch", fetch); + renderMeeting(); + const row = screen.getByText(other.title).closest("button")!; + fireEvent.pointerEnter(row); + fireEvent.focus(row); + expect(fetch).not.toHaveBeenCalled(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect(String(fetch.mock.calls[0]?.[0])).toContain("/api/meetings/meeting-2"); + await act(async () => { + finish(new Response(JSON.stringify(other))); + }); + fireEvent.pointerEnter(row); + fireEvent.focus(row); + expect(fetch).toHaveBeenCalledTimes(1); + expect(client.getQueryData(["/api/meetings", other.id])).toEqual(other); +}); + +it("does not fetch details when the pointer crosses ten meeting rows without dwelling", async () => { + const items = Array.from({ length: 10 }, (_, i) => meeting(`other-${i}`)); + client.setQueryData(MEETING_HISTORY_QUERY_KEY, { + pages: [{ items, total: items.length, offset: 0, activeMeeting: null }], + pageParams: [0], + }); + const fetch = vi.fn(async () => Response.json(items[0])); + vi.stubGlobal("fetch", fetch); + renderMeeting(); + for (const item of items) { + const row = screen.getByText(item.title).closest("button")!; + fireEvent.pointerEnter(row); + fireEvent.pointerLeave(row); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 180)); + }); + expect(fetch).not.toHaveBeenCalled(); +}); + +it("keeps unfiltered match navigation and restores all rows after clearing a filter", () => { + renderMeeting(); + observed.scrollToIndex.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Next match" })); + expect(observed.scrollToIndex).toHaveBeenCalledWith(1, { align: "center" }); + const search = screen.getByTestId("meeting-review-search"); + fireEvent.change(search, { target: { value: "passage 1" } }); + expect(screen.getByTestId("meeting-transcript-segment-segment-1")).toHaveTextContent("Review passage 1"); + expect(screen.queryByTestId("meeting-transcript-segment-segment-0")).not.toBeInTheDocument(); + fireEvent.change(search, { target: { value: "" } }); + expect(screen.getByText("Review passage 0")).toBeInTheDocument(); + expect(screen.getByText("Review passage 2")).toBeInTheDocument(); +}); + +it("typing a meeting question renders only the composer and keeps the draft across views", async () => { + renderMeeting(); + fireEvent.click(screen.getByTestId("meeting-workspace-tab-chat")); + const questionField = () => screen.getByPlaceholderText("What did we decide about the launch?"); + const askButton = () => + screen.getAllByRole("button", { name: "Ask meeting" }).find((button) => !button.dataset.testid)!; + expect(askButton()).toBeDisabled(); + observed.i18nReads = 0; + + let question = ""; + for (const character of "What did we decide?") { + question += character; + fireEvent.change(questionField(), { target: { value: question } }); + } + + expect(questionField()).toHaveValue(question); + expect(askButton()).toBeEnabled(); + // One composer render per keystroke; the whole Meetings page rendered before. + expect(observed.i18nReads).toBe(question.length); + fireEvent.click(screen.getByTestId("meeting-workspace-tab-notes")); + fireEvent.click(screen.getByTestId("meeting-workspace-tab-chat")); + expect(questionField()).toHaveValue(question); + + observed.apiRequest.mockResolvedValueOnce( + new Response(JSON.stringify({ message: { content: "Launch moves to Friday.", citations: [] } })), + ); + fireEvent.click(askButton()); + expect(await screen.findByText("Launch moves to Friday.")).toBeInTheDocument(); + expect(observed.apiRequest).toHaveBeenCalledWith("POST", "/api/meetings/meeting-1/chat", { question }); + expect(questionField()).toHaveValue(""); + expect(askButton()).toBeDisabled(); +}); + +it("typing a correction renders only its row, and Escape retains the original text", () => { + renderMeeting(); + fireEvent.click(screen.getByTestId("meeting-segment-edit-segment-0")); + observed.rowFormats = 0; + for (let i = 1; i <= 20; i++) { + fireEvent.change(screen.getByTestId("meeting-segment-edit-input-segment-0"), { + target: { value: `Correction ${i}` }, + }); + } + expect(observed.rowFormats).toBe(20); + fireEvent.keyDown(screen.getByTestId("meeting-segment-edit-input-segment-0"), { key: "Escape" }); + expect(screen.getByText("Review passage 0")).toBeInTheDocument(); + expect(screen.queryByTestId("meeting-segment-edit-input-segment-0")).not.toBeInTheDocument(); +}); + +it("an incoming live segment leaves unchanged visible rows unrendered", async () => { + const detail = meeting(); + detail.audioAssets = []; + detail.state = "recording"; + detail.segments = detail.segments.map((segment) => ({ ...segment, revision: "live" })); + renderMeeting(detail); + observed.rowFormats = 0; + await act(async () => { + client.setQueryData(["/api/meetings", detail.id], (current) => ({ + ...current!, + segments: [...current!.segments, { ...detail.segments[0], id: "new-segment", startMs: 40_000 }], + })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(observed.rowFormats).toBe(0); + + await act(async () => { + client.setQueryData(["/api/meetings", detail.id], (current) => ({ + ...current!, + segments: current!.segments.map((segment) => + segment.id === "segment-1" ? { ...segment, text: "Updated live passage" } : segment, + ), + })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(screen.getByText("Updated live passage")).toBeInTheDocument(); + expect(observed.rowFormats).toBe(1); +}); + +it("memoized rows refresh speaker labels and translations without a segment text change", async () => { + const detail = meeting(); + detail.segments = detail.segments.map((segment) => ({ ...segment, speakerLabel: "" })); + renderMeeting(detail); + const row = () => screen.getByTestId("meeting-transcript-segment-segment-0"); + expect(row()).toHaveTextContent("Meeting audio"); + await act(async () => { + window.dispatchEvent(new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY, newValue: "de" })); + }); + await waitFor(() => expect(row()).not.toHaveTextContent("Meeting audio")); + expect(row()).toHaveTextContent("Review passage 0"); + await act(async () => { + client.setQueryData(["/api/meetings", detail.id], (current) => ({ + ...current!, + segments: current!.segments.map((segment) => + segment.id === "segment-0" ? { ...segment, speakerLabel: "Ada" } : segment, + ), + })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(row()).toHaveTextContent("Ada"); + expect(row()).toHaveTextContent("Review passage 0"); +}); diff --git a/Frontend/client/src/pages/Meetings.tsx b/Frontend/client/src/pages/Meetings.tsx index 56d945dc..e3e8f18e 100644 --- a/Frontend/client/src/pages/Meetings.tsx +++ b/Frontend/client/src/pages/Meetings.tsx @@ -38,6 +38,7 @@ import { X, } from "lucide-react"; import { apiUrl } from "@/lib/backend"; +import { type TextDraftStore, useTextDraft, useTextDraftStore } from "@/lib/text-draft-store"; import { apiRequest, OUTLOOK_SYNC_REQUEST_TIMEOUT_MS } from "@/lib/queryClient"; import { fetchWithTimeout } from "@/lib/fetch-with-timeout"; import { @@ -107,12 +108,14 @@ import { } from "@/components/meeting/SpeakerAttendeeAssignments"; import { saveWorkspaceMeetingNote, useMeetingNotesAutosave } from "@/components/meeting/useMeetingNotesAutosave"; import { - activeReviewSegmentId, + createReviewPlaybackLookup, matchingReviewSegmentIds, nextReviewMatchId, reviewTimeRangeBounds, type ReviewTimeRange, } from "@/lib/meeting-review-timeline"; +import { createMeetingSegmentProjector, type DisplayMeetingSegment } from "@/lib/meeting-segment-display"; +import { createMeetingDetailPrefetch } from "@/lib/meeting-detail-prefetch"; import { Dialog, DialogContent, @@ -479,7 +482,205 @@ function highlightTranscriptMatch(text: string, query: string) { ); } -type DisplayMeetingSegment = MeetingSegment & { label: string }; +const MeetingTranscriptRow = memo(function MeetingTranscriptRow({ + segment, + active, + search, + hasPlayableAudio, + onPlay, + canAssignSpeakers, + onAssignSpeaker, + canEdit, + editing, + draft, + setDraft, + beginEdit, + cancelEdit, + saving, + onSave, + onUndo, +}: { + segment: DisplayMeetingSegment; + active: boolean; + search: string; + hasPlayableAudio: boolean; + onPlay: (startMs: number) => void; + canAssignSpeakers: boolean; + onAssignSpeaker: (speakerId: string) => void; + canEdit: boolean; + editing: boolean; + draft: string; + setDraft: (draft: string) => void; + beginEdit: (segment: DisplayMeetingSegment) => void; + cancelEdit: () => void; + saving: boolean; + onSave: (segment: DisplayMeetingSegment, text: string) => void; + onUndo: (segment: DisplayMeetingSegment) => void; +}) { + const { t, formatNumber } = useI18n(); + return ( +
{ + if (canEdit && event.key.toLocaleLowerCase() === "e" && event.target === event.currentTarget) { + event.preventDefault(); + beginEdit(segment); + } + }} + > + +
+
+ {canAssignSpeakers && segment.speakerId ? ( + + ) : ( + + {highlightTranscriptMatch(segment.label, search)} + + )} + {segment.editVersion > 0 && ( + + {t("Edited")} + + )} +
+ {editing ? ( +
+