From b0604e385255d4165cbf1952fb02b6b034509c6f Mon Sep 17 00:00:00 2001
From: ComputelessComputer
<63365510+ComputelessComputer@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:18:49 +0900
Subject: [PATCH 1/2] Optimize transcript rendering performance
Reduce transcript word rerenders during playback/search and speed up transcript search matching.
---
.../note-input/search/matching.test.ts | 60 +++++
.../components/note-input/search/matching.ts | 48 ++--
.../transcript/renderer/segment.test.tsx | 214 ++++++++++++++++++
.../transcript/renderer/segment.tsx | 91 +++++++-
.../transcript/renderer/transcript.tsx | 28 ++-
.../transcript/renderer/utils.test.ts | 42 +++-
.../note-input/transcript/renderer/utils.ts | 36 +++
.../transcript/renderer/word-span.tsx | 56 ++---
8 files changed, 505 insertions(+), 70 deletions(-)
create mode 100644 apps/desktop/src/session/components/note-input/search/matching.test.ts
create mode 100644 apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx
diff --git a/apps/desktop/src/session/components/note-input/search/matching.test.ts b/apps/desktop/src/session/components/note-input/search/matching.test.ts
new file mode 100644
index 0000000000..68cf2a0ebf
--- /dev/null
+++ b/apps/desktop/src/session/components/note-input/search/matching.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import { getTranscriptMatches, type SearchOptions } from "./matching";
+
+const defaultOptions: SearchOptions = {
+ caseSensitive: false,
+ wholeWord: false,
+};
+
+describe("getTranscriptMatches", () => {
+ it("returns transcript word spans in match order", () => {
+ const spans = [
+ createSpan("word-1", "Hello"),
+ createSpan("word-2", "world"),
+ createSpan("word-3", "hello"),
+ ];
+
+ expect(
+ getTranscriptMatches(spans, "hello", defaultOptions).map(
+ (match) => match.id,
+ ),
+ ).toEqual(["word-1", "word-3"]);
+ });
+
+ it("maps phrase matches to the first matching word", () => {
+ const spans = [
+ createSpan("word-1", "plan"),
+ createSpan("word-2", "the"),
+ createSpan("word-3", "launch"),
+ ];
+
+ expect(
+ getTranscriptMatches(spans, "the launch", defaultOptions).map(
+ (match) => match.id,
+ ),
+ ).toEqual(["word-2"]);
+ });
+
+ it("keeps whole-word matching behavior", () => {
+ const spans = [
+ createSpan("word-1", "sync"),
+ createSpan("word-2", "async"),
+ createSpan("word-3", "syncing"),
+ ];
+
+ expect(
+ getTranscriptMatches(spans, "sync", {
+ ...defaultOptions,
+ wholeWord: true,
+ }).map((match) => match.id),
+ ).toEqual(["word-1"]);
+ });
+});
+
+function createSpan(id: string, text: string) {
+ const span = document.createElement("span");
+ span.dataset.wordId = id;
+ span.textContent = text;
+ return span;
+}
diff --git a/apps/desktop/src/session/components/note-input/search/matching.ts b/apps/desktop/src/session/components/note-input/search/matching.ts
index c16ab9c616..171811a71c 100644
--- a/apps/desktop/src/session/components/note-input/search/matching.ts
+++ b/apps/desktop/src/session/components/note-input/search/matching.ts
@@ -94,28 +94,36 @@ export function getTranscriptMatches(
const searchText = prepareText(fullText, opts.caseSensitive);
const indices = findOccurrences(searchText, prepared, opts.wholeWord);
const result: MatchResult[] = [];
+ let spanIndex = 0;
for (const idx of indices) {
- for (let i = 0; i < spanPositions.length; i++) {
- const { start, end } = spanPositions[i];
- if (idx >= start && idx < end) {
- result.push({
- element: allSpans[i],
- id: allSpans[i].dataset.wordId || null,
- });
- break;
- }
- if (
- i < spanPositions.length - 1 &&
- idx >= end &&
- idx < spanPositions[i + 1].start
- ) {
- result.push({
- element: allSpans[i + 1],
- id: allSpans[i + 1].dataset.wordId || null,
- });
- break;
- }
+ while (
+ spanIndex < spanPositions.length - 1 &&
+ idx >= spanPositions[spanIndex].end &&
+ idx >= spanPositions[spanIndex + 1].start
+ ) {
+ spanIndex += 1;
+ }
+
+ const { start, end } = spanPositions[spanIndex];
+ if (idx >= start && idx < end) {
+ result.push({
+ element: allSpans[spanIndex],
+ id: allSpans[spanIndex].dataset.wordId || null,
+ });
+ continue;
+ }
+
+ if (
+ spanIndex < spanPositions.length - 1 &&
+ idx >= end &&
+ idx < spanPositions[spanIndex + 1].start
+ ) {
+ const nextSpan = allSpans[spanIndex + 1];
+ result.push({
+ element: nextSpan,
+ id: nextSpan.dataset.wordId || null,
+ });
}
}
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx
new file mode 100644
index 0000000000..fe986cdaf9
--- /dev/null
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.test.tsx
@@ -0,0 +1,214 @@
+import { render } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ EMPTY_TRANSCRIPT_SEARCH,
+ SegmentRenderer,
+ type TranscriptSearchRenderState,
+} from "./segment";
+
+import type { Segment, SegmentWord } from "~/stt/live-segment";
+
+const mocks = vi.hoisted(() => ({
+ wordSpan: vi.fn(
+ ({
+ displayText,
+ isActiveMatch,
+ }: {
+ displayText: string;
+ isActiveMatch?: boolean;
+ }) => (
+
+ {displayText}
+
+ ),
+ ),
+}));
+
+vi.mock("./segment-header", () => ({
+ SegmentHeader: () => null,
+}));
+
+vi.mock("./word-span", () => ({
+ WordSpan: mocks.wordSpan,
+}));
+
+describe("SegmentRenderer", () => {
+ beforeEach(() => {
+ mocks.wordSpan.mockClear();
+ });
+
+ it("skips playback rerenders while the active line is unchanged", () => {
+ const segment = createSegment();
+ const seekAndPlay = vi.fn();
+ const view = render(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+
+ view.rerender(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+ });
+
+ it("rerenders playback when the active line changes", () => {
+ const segment = createSegment();
+ const seekAndPlay = vi.fn();
+ const view = render(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+
+ view.rerender(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(8);
+ });
+
+ it("skips active-match navigation outside the segment", () => {
+ const segment = createSegment();
+ const seekAndPlay = vi.fn();
+ const search = createSearch("outside-1");
+ const view = render(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+
+ view.rerender(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+ });
+
+ it("rerenders active-match navigation inside the segment", () => {
+ const segment = createSegment();
+ const seekAndPlay = vi.fn();
+ const view = render(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(4);
+
+ view.rerender(
+ ,
+ );
+
+ expect(mocks.wordSpan).toHaveBeenCalledTimes(8);
+ });
+});
+
+function createSearch(activeMatchId: string): TranscriptSearchRenderState {
+ return {
+ query: "line",
+ activeMatchId,
+ caseSensitive: false,
+ wholeWord: false,
+ };
+}
+
+function createSegment(): Segment {
+ return {
+ id: "segment-1",
+ text: "First line. Second line.",
+ start_ms: 100,
+ end_ms: 1800,
+ key: {
+ channel: "MixedCapture",
+ speaker_index: null,
+ speaker_human_id: null,
+ },
+ words: [
+ createWord("word-1", "First", 100, 300),
+ createWord("word-2", "line.", 300, 900),
+ createWord("word-3", "Second", 1200, 1400),
+ createWord("word-4", "line.", 1400, 1800),
+ ],
+ };
+}
+
+function createWord(
+ id: string,
+ text: string,
+ startMs: number,
+ endMs: number,
+): SegmentWord {
+ return {
+ id,
+ text,
+ start_ms: startMs,
+ end_ms: endMs,
+ channel: "MixedCapture",
+ is_final: true,
+ };
+}
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx
index f2c7bbbfe8..35b30bc780 100644
--- a/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/segment.tsx
@@ -3,12 +3,31 @@ import { memo, useMemo } from "react";
import { cn } from "@hypr/utils";
import { SegmentHeader } from "./segment-header";
-import { groupWordsIntoLines } from "./utils";
+import {
+ getActiveLineIndex,
+ groupWordsIntoLines,
+ type HighlightSegment,
+} from "./utils";
import { WordSpan } from "./word-span";
+import { createHighlightSegments } from "~/session/components/note-input/search/matching";
import type { Segment, SegmentWord } from "~/stt/live-segment";
import { SpeakerLabelManager } from "~/stt/live-segment";
+export type TranscriptSearchRenderState = {
+ query: string;
+ activeMatchId: string | null;
+ caseSensitive: boolean;
+ wholeWord: boolean;
+};
+
+export const EMPTY_TRANSCRIPT_SEARCH: TranscriptSearchRenderState = {
+ query: "",
+ activeMatchId: null,
+ caseSensitive: false,
+ wholeWord: false,
+};
+
function getSegmentTimeRange(
segment: Segment,
offsetMs: number,
@@ -30,6 +49,7 @@ export const SegmentRenderer = memo(
currentMs,
seekAndPlay,
audioExists,
+ search,
}: {
segment: Segment;
offsetMs: number;
@@ -38,11 +58,31 @@ export const SegmentRenderer = memo(
currentMs: number;
seekAndPlay: (word: SegmentWord) => void;
audioExists: boolean;
+ search: TranscriptSearchRenderState;
}) => {
const lines = useMemo(
() => groupWordsIntoLines(segment.words),
[segment.words],
);
+ const highlightSegmentsByWord = useMemo(() => {
+ if (!search.query) {
+ return null;
+ }
+
+ const highlights = new Map();
+ for (const word of segment.words) {
+ highlights.set(
+ word,
+ createHighlightSegments(
+ word.text,
+ search.query,
+ search.caseSensitive,
+ search.wholeWord,
+ ),
+ );
+ }
+ return highlights;
+ }, [search.caseSensitive, search.query, search.wholeWord, segment.words]);
return (
@@ -83,6 +123,12 @@ export const SegmentRenderer = memo(
displayText={word.text}
audioExists={audioExists}
onClickWord={seekAndPlay}
+ highlightSegments={
+ highlightSegmentsByWord?.get(word) ?? undefined
+ }
+ isActiveMatch={
+ Boolean(word.id) && word.id === search.activeMatchId
+ }
/>
))}
@@ -104,7 +150,10 @@ export const SegmentRenderer = memo(
return false;
}
- // Smart time comparison: only re-render if time change affects which line is active
+ if (!canReuseSegmentForSearch(prev, next)) {
+ return false;
+ }
+
if (prev.currentMs === next.currentMs) return true;
const range = getSegmentTimeRange(prev.segment, prev.offsetMs);
@@ -119,11 +168,41 @@ export const SegmentRenderer = memo(
next.currentMs >= range.start &&
next.currentMs <= range.end;
- // If neither time is in this segment's range, no visual change
if (!prevInRange && !nextInRange) return true;
- // If both are in range, the active line might have changed — need to re-render
- // If one is in range and the other isn't, definitely need to re-render
- return false;
+ return (
+ getActiveLineIndex(prev.segment.words, prev.offsetMs, prev.currentMs) ===
+ getActiveLineIndex(next.segment.words, next.offsetMs, next.currentMs)
+ );
},
);
+
+function canReuseSegmentForSearch(
+ prev: { segment: Segment; search: TranscriptSearchRenderState },
+ next: { segment: Segment; search: TranscriptSearchRenderState },
+) {
+ if (
+ prev.search.query !== next.search.query ||
+ prev.search.caseSensitive !== next.search.caseSensitive ||
+ prev.search.wholeWord !== next.search.wholeWord
+ ) {
+ return false;
+ }
+
+ if (prev.search.activeMatchId === next.search.activeMatchId) {
+ return true;
+ }
+
+ return (
+ !segmentContainsWordId(prev.segment, prev.search.activeMatchId) &&
+ !segmentContainsWordId(next.segment, next.search.activeMatchId)
+ );
+}
+
+function segmentContainsWordId(segment: Segment, wordId: string | null) {
+ if (!wordId) {
+ return false;
+ }
+
+ return segment.words.some((word) => word.id === wordId);
+}
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx
index 0009924f39..812e8cbdc6 100644
--- a/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/transcript.tsx
@@ -2,8 +2,13 @@ import { memo, useCallback, useEffect, useMemo } from "react";
import { cn } from "@hypr/utils";
+import { useSearch } from "../../search/context";
import { useRenderedTranscriptData, useTranscriptOffset } from "./data-hooks";
-import { SegmentRenderer } from "./segment";
+import {
+ EMPTY_TRANSCRIPT_SEARCH,
+ SegmentRenderer,
+ type TranscriptSearchRenderState,
+} from "./segment";
import {
createSegmentKey,
segmentsShallowEqual,
@@ -97,6 +102,7 @@ const SegmentsList = memo(
maxSpeakerNumber?: number;
}) => {
const store = main.UI.useStore(main.STORE_ID);
+ const search = useSearch();
const speakerLabelManager = useMemo(() => {
if (!store) {
return new SpeakerLabelManager();
@@ -104,6 +110,25 @@ const SegmentsList = memo(
const ctx = defaultRenderLabelContext(store);
return SpeakerLabelManager.fromSegments(segments, ctx, maxSpeakerNumber);
}, [maxSpeakerNumber, segments, store]);
+ const transcriptSearch = useMemo(() => {
+ const query = search?.query.trim() ?? "";
+ if (!search?.isVisible || !query) {
+ return EMPTY_TRANSCRIPT_SEARCH;
+ }
+
+ return {
+ query,
+ activeMatchId: search.activeMatchId,
+ caseSensitive: search.caseSensitive,
+ wholeWord: search.wholeWord,
+ };
+ }, [
+ search?.activeMatchId,
+ search?.caseSensitive,
+ search?.isVisible,
+ search?.query,
+ search?.wholeWord,
+ ]);
const seekAndPlay = useCallback(
(word: SegmentWord) => {
@@ -143,6 +168,7 @@ const SegmentsList = memo(
currentMs={currentMs}
seekAndPlay={seekAndPlay}
audioExists={audioExists}
+ search={transcriptSearch}
/>
))}
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts
index 2c2961b2f3..5c0aa71cc4 100644
--- a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.test.ts
@@ -1,9 +1,13 @@
import chroma from "chroma-js";
import { describe, expect, it } from "vitest";
-import { getSegmentColor, getSegmentColorVars } from "./utils";
+import {
+ getActiveLineIndex,
+ getSegmentColor,
+ getSegmentColorVars,
+} from "./utils";
-import type { SegmentKey } from "~/stt/live-segment";
+import type { SegmentKey, SegmentWord } from "~/stt/live-segment";
describe("transcript renderer utils", () => {
it("uses a brighter speaker color for dark mode", () => {
@@ -30,4 +34,38 @@ describe("transcript renderer utils", () => {
"--segment-color-dark": getSegmentColor(key, "dark"),
});
});
+
+ it("finds the active transcript line without building line groups", () => {
+ const words: SegmentWord[] = [
+ createWord("word-1", "Hello", 100, 400),
+ createWord("word-2", "world.", 400, 900),
+ createWord("word-3", "Next", 1400, 1600),
+ createWord("word-4", "line!", 1600, 2100),
+ ];
+
+ expect(getActiveLineIndex(words, 50, 0)).toBeNull();
+ expect(getActiveLineIndex(words, 50, 149)).toBeNull();
+ expect(getActiveLineIndex(words, 50, 150)).toBe(0);
+ expect(getActiveLineIndex(words, 50, 950)).toBe(0);
+ expect(getActiveLineIndex(words, 50, 1200)).toBeNull();
+ expect(getActiveLineIndex(words, 50, 1450)).toBe(1);
+ expect(getActiveLineIndex(words, 50, 2150)).toBe(1);
+ expect(getActiveLineIndex(words, 50, 2200)).toBeNull();
+ });
});
+
+function createWord(
+ id: string,
+ text: string,
+ startMs: number,
+ endMs: number,
+): SegmentWord {
+ return {
+ id,
+ text,
+ start_ms: startMs,
+ end_ms: endMs,
+ channel: "MixedCapture",
+ is_final: true,
+ };
+}
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts
index d95c510a78..1fb2499dba 100644
--- a/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/utils.ts
@@ -46,6 +46,42 @@ export function groupWordsIntoLines(words: SegmentWord[]): SentenceLine[] {
return lines;
}
+export function getActiveLineIndex(
+ words: SegmentWord[],
+ offsetMs: number,
+ currentMs: number,
+): number | null {
+ if (currentMs <= 0 || words.length === 0) return null;
+
+ let lineIndex = 0;
+ let lineStartMs = words[0]!.start_ms;
+
+ for (let index = 0; index < words.length; index += 1) {
+ const word = words[index]!;
+ const text = word.text.trim();
+ const closesLine =
+ text.endsWith(".") ||
+ text.endsWith("?") ||
+ text.endsWith("!") ||
+ index === words.length - 1;
+
+ if (!closesLine) {
+ continue;
+ }
+
+ const start = offsetMs + lineStartMs;
+ const end = offsetMs + word.end_ms;
+ if (currentMs >= start && currentMs <= end) {
+ return lineIndex;
+ }
+
+ lineIndex += 1;
+ lineStartMs = words[index + 1]?.start_ms ?? lineStartMs;
+ }
+
+ return null;
+}
+
export function formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx
index 4ed8e63f03..a274d1ceeb 100644
--- a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx
+++ b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx
@@ -1,11 +1,9 @@
-import { Fragment, useMemo } from "react";
+import { Fragment, memo, useMemo } from "react";
import { cn } from "@hypr/utils";
import type { HighlightSegment } from "./utils";
-import { useSearch } from "~/session/components/note-input/search/context";
-import { createHighlightSegments } from "~/session/components/note-input/search/matching";
import type { SegmentWord } from "~/stt/live-segment";
import { isTranscriptWordSeekable } from "~/stt/timing";
@@ -14,21 +12,16 @@ interface WordSpanProps {
displayText: string;
audioExists: boolean;
onClickWord: (word: SegmentWord) => void;
+ highlightSegments?: HighlightSegment[];
+ isActiveMatch?: boolean;
}
-export function WordSpan(props: WordSpanProps) {
- const searchHighlights = useTranscriptSearchHighlights(
- props.word,
- props.displayText,
- );
- const highlights = searchHighlights ?? {
- segments: [{ text: props.displayText, isMatch: false }],
- isActive: false,
- };
+export const WordSpan = memo(function WordSpan(props: WordSpanProps) {
const content = useHighlightedContent(
props.word,
- highlights.segments,
- highlights.isActive,
+ props.displayText,
+ props.highlightSegments,
+ props.isActiveMatch ?? false,
);
const canSeek = props.audioExists && isTranscriptWordSeekable(props.word);
const className = useMemo(
@@ -49,38 +42,19 @@ export function WordSpan(props: WordSpanProps) {
{content}
);
-}
-
-function useTranscriptSearchHighlights(word: SegmentWord, displayText: string) {
- const search = useSearch();
- const query = search?.query?.trim() ?? "";
- const isVisible = Boolean(search?.isVisible);
- const activeMatchId = search?.activeMatchId ?? null;
- const caseSensitive = search?.caseSensitive ?? false;
- const wholeWord = search?.wholeWord ?? false;
-
- const segments = useMemo(() => {
- const text = displayText ?? "";
- if (!text) {
- return [{ text: "", isMatch: false }];
- }
-
- if (!isVisible || !query) {
- return [{ text, isMatch: false }];
- }
-
- return createHighlightSegments(text, query, caseSensitive, wholeWord);
- }, [caseSensitive, displayText, isVisible, query, wholeWord]);
-
- return { segments, isActive: word.id === activeMatchId };
-}
+});
function useHighlightedContent(
word: SegmentWord,
- segments: HighlightSegment[],
+ displayText: string,
+ segments: HighlightSegment[] | undefined,
isActive: boolean,
) {
return useMemo(() => {
+ if (!segments) {
+ return displayText;
+ }
+
const baseKey = word.id ?? word.text ?? "word";
return segments.map((segment, index) =>
@@ -95,5 +69,5 @@ function useHighlightedContent(
{segment.text}
),
);
- }, [isActive, segments, word.id, word.text]);
+ }, [displayText, isActive, segments, word.id, word.text]);
}
From 41debd63df35a486f59cb8b0a6c7754a2154d83c Mon Sep 17 00:00:00 2001
From: ComputelessComputer
<63365510+ComputelessComputer@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:34:18 +0900
Subject: [PATCH 2/2] Narrow transcript row subscriptions
Move row revision tracking into TinyBase hooks and avoid whole transcript table subscriptions in session transcript state.
---
.../note-input/transcript/index.test.tsx | 32 ++++----
.../transcript/render-request-hooks.ts | 61 +--------------
.../components/note-input/transcript/state.ts | 8 +-
.../src/session/components/shared.test.ts | 6 +-
.../desktop/src/session/components/shared.tsx | 8 +-
.../src/store/tinybase/hooks/index.tsx | 75 ++++++++++++++++++-
6 files changed, 108 insertions(+), 82 deletions(-)
diff --git a/apps/desktop/src/session/components/note-input/transcript/index.test.tsx b/apps/desktop/src/session/components/note-input/transcript/index.test.tsx
index 52870ed1e6..63b28b6e88 100644
--- a/apps/desktop/src/session/components/note-input/transcript/index.test.tsx
+++ b/apps/desktop/src/session/components/note-input/transcript/index.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { act, render, screen } from "@testing-library/react";
import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -7,13 +7,11 @@ import { Transcript } from "./index";
const {
useSliceRowIdsMock,
useStoreMock,
- useTableMock,
useListenerMock,
useAudioPlayerMock,
} = vi.hoisted(() => ({
useSliceRowIdsMock: vi.fn(),
useStoreMock: vi.fn(),
- useTableMock: vi.fn(),
useListenerMock: vi.fn(),
useAudioPlayerMock: vi.fn(),
}));
@@ -26,7 +24,6 @@ vi.mock("~/store/tinybase/store/main", () => ({
UI: {
useSliceRowIds: useSliceRowIdsMock,
useStore: useStoreMock,
- useTable: useTableMock,
useCheckpoints: vi.fn(() => null),
useIndexes: vi.fn(() => null),
},
@@ -86,16 +83,12 @@ describe("Transcript", () => {
partialWordsByChannel: Record;
partialHintsByChannel: Record;
};
+ let transcriptRowListener: (() => void) | null;
let transcriptWordsJson: string;
- let transcriptsTable: Record;
beforeEach(() => {
+ transcriptRowListener = null;
transcriptWordsJson = "[]";
- transcriptsTable = {
- [transcriptId]: {
- words: transcriptWordsJson,
- },
- };
listenerState = {
getSessionMode: () => "active",
@@ -112,6 +105,16 @@ describe("Transcript", () => {
useSliceRowIdsMock.mockReturnValue([transcriptId]);
useStoreMock.mockReturnValue({
+ addRowListener: vi.fn(
+ (tableId: string, rowId: string, listener: () => void) => {
+ if (tableId === "transcripts" && rowId === transcriptId) {
+ transcriptRowListener = listener;
+ }
+
+ return "listener-1";
+ },
+ ),
+ delListener: vi.fn(),
getCell: vi.fn(
(tableId: string, rowId: string, cellId: "words" | "speaker_hints") => {
if (
@@ -126,7 +129,6 @@ describe("Transcript", () => {
},
),
});
- useTableMock.mockImplementation(() => transcriptsTable);
useListenerMock.mockImplementation((selector) => selector(listenerState));
useAudioPlayerMock.mockReturnValue({ audioExists: false });
});
@@ -140,11 +142,9 @@ describe("Transcript", () => {
expect(screen.getByTestId("listening-state").textContent).toBe("listening");
transcriptWordsJson = '[{"id":"word-1","text":" Hello"}]';
- transcriptsTable = {
- [transcriptId]: {
- words: transcriptWordsJson,
- },
- };
+ act(() => {
+ transcriptRowListener?.();
+ });
view.rerender();
diff --git a/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts b/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts
index bf2706d216..f1a38ac244 100644
--- a/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts
+++ b/apps/desktop/src/session/components/note-input/transcript/render-request-hooks.ts
@@ -1,10 +1,11 @@
-import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
+import { useMemo } from "react";
import type {
RenderTranscriptHuman,
RenderTranscriptRequest,
} from "@hypr/plugin-transcription";
+import { getUniqueRowIds, useStoreRowsRevision } from "~/store/tinybase/hooks";
import * as main from "~/store/tinybase/store/main";
import {
buildRenderTranscriptRequestFromRows,
@@ -14,7 +15,6 @@ import {
} from "~/stt/render-transcript";
import { parseTranscriptHints, parseTranscriptWords } from "~/stt/utils";
-type RenderTableId = "transcripts" | "mapping_session_participant" | "humans";
type UiStore = NonNullable>;
export type TranscriptRowWithId = {
@@ -146,41 +146,6 @@ function useRenderData(
return { request, transcriptRows };
}
-function useStoreRowsRevision(
- store: UiStore | undefined,
- tableId: RenderTableId,
- rowIds: readonly string[],
-): number {
- const revisionRef = useRef(0);
- const rowIdsKey = getRowIdsKey(rowIds);
- const subscribedRowIds = useMemo(() => getUniqueRowIds(rowIds), [rowIdsKey]);
-
- const subscribe = useCallback(
- (notify: () => void) => {
- if (!store || subscribedRowIds.length === 0) {
- return noop;
- }
-
- const listenerIds = subscribedRowIds.map((rowId) =>
- store.addRowListener(tableId, rowId, () => {
- revisionRef.current += 1;
- notify();
- }),
- );
-
- return () => {
- for (const listenerId of listenerIds) {
- store.delListener(listenerId);
- }
- };
- },
- [store, subscribedRowIds, tableId],
- );
- const getSnapshot = useCallback(() => revisionRef.current, []);
-
- return useSyncExternalStore(subscribe, getSnapshot, getZero);
-}
-
function getTranscriptRow(store: UiStore, transcriptId: string): TranscriptRow {
const startedAt = store.getCell("transcripts", transcriptId, "started_at");
@@ -238,26 +203,4 @@ function getRowIdsKey(rowIds: readonly string[]): string {
return getUniqueRowIds(rowIds).join("\u0000");
}
-function getUniqueRowIds(rowIds: readonly string[]): string[] {
- const uniqueRowIds: string[] = [];
- const seen = new Set();
-
- for (const rowId of rowIds) {
- if (!rowId || seen.has(rowId)) {
- continue;
- }
-
- uniqueRowIds.push(rowId);
- seen.add(rowId);
- }
-
- return uniqueRowIds;
-}
-
-function noop() {}
-
-function getZero() {
- return 0;
-}
-
const emptyIds: string[] = [];
diff --git a/apps/desktop/src/session/components/note-input/transcript/state.ts b/apps/desktop/src/session/components/note-input/transcript/state.ts
index a9dc556f5e..fbba2e3ec9 100644
--- a/apps/desktop/src/session/components/note-input/transcript/state.ts
+++ b/apps/desktop/src/session/components/note-input/transcript/state.ts
@@ -3,6 +3,7 @@ import { useMemo } from "react";
import type { DegradedError } from "@hypr/plugin-transcription";
import { useAudioPlayer } from "~/audio-player";
+import { useMainStoreRowsRevision } from "~/store/tinybase/hooks";
import * as main from "~/store/tinybase/store/main";
import { getLiveCaptureUiMode } from "~/store/zustand/listener/general-shared";
import { useListener } from "~/stt/contexts";
@@ -109,7 +110,10 @@ function useTranscriptContent(sessionId: string) {
sessionId,
main.STORE_ID,
) ?? [];
- const transcriptsTable = main.UI.useTable("transcripts", main.STORE_ID);
+ const transcriptRowsRevision = useMainStoreRowsRevision(
+ "transcripts",
+ transcriptIds,
+ );
const liveSegments = useListener((state) => state.liveSegments);
const store = main.UI.useStore(main.STORE_ID);
@@ -121,7 +125,7 @@ function useTranscriptContent(sessionId: string) {
return transcriptIds.some(
(transcriptId) => parseTranscriptWords(store, transcriptId).length > 0,
);
- }, [store, transcriptIds, transcriptsTable]);
+ }, [store, transcriptIds, transcriptRowsRevision]);
return {
transcriptIds,
diff --git a/apps/desktop/src/session/components/shared.test.ts b/apps/desktop/src/session/components/shared.test.ts
index b7fccbe47f..698c95968f 100644
--- a/apps/desktop/src/session/components/shared.test.ts
+++ b/apps/desktop/src/session/components/shared.test.ts
@@ -67,8 +67,10 @@ vi.mock("~/store/tinybase/store/main", () => ({
return [];
},
- useStore: () => ({}),
- useTable: () => ({}),
+ useStore: () => ({
+ addRowListener: vi.fn(() => "listener-1"),
+ delListener: vi.fn(),
+ }),
},
}));
diff --git a/apps/desktop/src/session/components/shared.tsx b/apps/desktop/src/session/components/shared.tsx
index 201056e7de..c107f1a635 100644
--- a/apps/desktop/src/session/components/shared.tsx
+++ b/apps/desktop/src/session/components/shared.tsx
@@ -5,6 +5,7 @@ import { Button } from "@hypr/ui/components/ui/button";
import { computeCurrentNoteTab } from "./compute-note-tab";
import { extractPlainText } from "~/search/contexts/engine/utils";
+import { useMainStoreRowsRevision } from "~/store/tinybase/hooks";
import * as main from "~/store/tinybase/store/main";
import type { Tab } from "~/store/zustand/tabs/schema";
import { type EditorView } from "~/store/zustand/tabs/schema";
@@ -20,7 +21,10 @@ export function useHasTranscript(sessionId: string): boolean {
sessionId,
main.STORE_ID,
) ?? [];
- const transcriptsTable = main.UI.useTable("transcripts", main.STORE_ID);
+ const transcriptRowsRevision = useMainStoreRowsRevision(
+ "transcripts",
+ transcriptIds,
+ );
const store = main.UI.useStore(main.STORE_ID);
return useMemo(() => {
@@ -31,7 +35,7 @@ export function useHasTranscript(sessionId: string): boolean {
return transcriptIds.some(
(transcriptId) => parseTranscriptWords(store, transcriptId).length > 0,
);
- }, [store, transcriptIds, transcriptsTable]);
+ }, [store, transcriptIds, transcriptRowsRevision]);
}
export function hasStoredNoteContent(value: unknown): boolean {
diff --git a/apps/desktop/src/store/tinybase/hooks/index.tsx b/apps/desktop/src/store/tinybase/hooks/index.tsx
index ec74b85ce1..ad8b3770c5 100644
--- a/apps/desktop/src/store/tinybase/hooks/index.tsx
+++ b/apps/desktop/src/store/tinybase/hooks/index.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import type {
IgnoredEvent,
@@ -206,3 +206,76 @@ export function useIgnoredEvents() {
unignoreSeries,
};
}
+
+type UiStore = NonNullable>;
+type StoreTableId = Parameters[0];
+
+export function useMainStoreRowsRevision(
+ tableId: StoreTableId,
+ rowIds: readonly string[],
+): number {
+ const store = main.UI.useStore(main.STORE_ID);
+
+ return useStoreRowsRevision(store, tableId, rowIds);
+}
+
+export function useStoreRowsRevision(
+ store: UiStore | undefined,
+ tableId: StoreTableId,
+ rowIds: readonly string[],
+): number {
+ const revisionRef = useRef(0);
+ const rowIdsKey = getRowIdsKey(rowIds);
+ const subscribedRowIds = useMemo(() => getUniqueRowIds(rowIds), [rowIdsKey]);
+
+ const subscribe = useCallback(
+ (notify: () => void) => {
+ if (!store || subscribedRowIds.length === 0) {
+ return noop;
+ }
+
+ const listenerIds = subscribedRowIds.map((rowId) =>
+ store.addRowListener(tableId, rowId, () => {
+ revisionRef.current += 1;
+ notify();
+ }),
+ );
+
+ return () => {
+ for (const listenerId of listenerIds) {
+ store.delListener(listenerId);
+ }
+ };
+ },
+ [store, subscribedRowIds, tableId],
+ );
+ const getSnapshot = useCallback(() => revisionRef.current, []);
+
+ return useSyncExternalStore(subscribe, getSnapshot, getZero);
+}
+
+function getRowIdsKey(rowIds: readonly string[]): string {
+ return getUniqueRowIds(rowIds).join("\u0000");
+}
+
+export function getUniqueRowIds(rowIds: readonly string[]): string[] {
+ const uniqueRowIds: string[] = [];
+ const seen = new Set();
+
+ for (const rowId of rowIds) {
+ if (!rowId || seen.has(rowId)) {
+ continue;
+ }
+
+ uniqueRowIds.push(rowId);
+ seen.add(rowId);
+ }
+
+ return uniqueRowIds;
+}
+
+function noop() {}
+
+function getZero() {
+ return 0;
+}