From 7bb91a0b440fa9cc202e3f97cd3befb646b24044 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:33:03 +0000 Subject: [PATCH 1/2] feat: implement lazy loading for session content - Load only metadata at startup for faster app launch - Load session content (transcripts, notes) on-demand when session is opened - Add useSessionContentLoader hook for components to trigger content loading - Show loading state while content is being fetched - Mark newly created sessions as loaded to avoid unnecessary loading - Clear content load state when sessions are reloaded Co-Authored-By: yujonglee --- .../components/main/body/sessions/index.tsx | 10 ++ apps/desktop/src/hooks/tinybase.tsx | 42 +++++++- .../store/tinybase/persister/session/index.ts | 3 +- .../tinybase/persister/session/load/index.ts | 79 ++++++++++++++- .../store/tinybase/persister/session/ops.ts | 95 +++++++++++++++++++ .../tinybase/persister/session/persister.ts | 2 +- .../src/store/tinybase/store/sessions.ts | 3 + 7 files changed, 227 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/main/body/sessions/index.tsx b/apps/desktop/src/components/main/body/sessions/index.tsx index 503ec8d879..d730a0638f 100644 --- a/apps/desktop/src/components/main/body/sessions/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/index.tsx @@ -11,6 +11,7 @@ import { cn } from "@hypr/utils"; import AudioPlayer from "../../../../contexts/audio-player"; import { useListener } from "../../../../contexts/listener"; import { useShell } from "../../../../contexts/shell"; +import { useSessionContentLoader } from "../../../../hooks/tinybase"; import { useAutoEnhance } from "../../../../hooks/useAutoEnhance"; import { useIsSessionEnhancing } from "../../../../hooks/useEnhancedNotes"; import { useStartListening } from "../../../../hooks/useStartListening"; @@ -85,12 +86,21 @@ export function TabContentNote({ }: { tab: Extract; }) { + const { isLoading: contentLoading } = useSessionContentLoader(tab.id); const listenerStatus = useListener((state) => state.live.status); const updateSessionTabState = useTabs((state) => state.updateSessionTabState); const { conn } = useSTTConnection(); const startListening = useStartListening(tab.id); const hasAttemptedAutoStart = useRef(false); + if (contentLoading) { + return ( +
+
Loading session...
+
+ ); + } + useEffect(() => { if (!tab.state.autoStart) { hasAttemptedAutoStart.current = false; diff --git a/apps/desktop/src/hooks/tinybase.tsx b/apps/desktop/src/hooks/tinybase.tsx index 81f97afd46..c9855dcdfb 100644 --- a/apps/desktop/src/hooks/tinybase.tsx +++ b/apps/desktop/src/hooks/tinybase.tsx @@ -1,4 +1,10 @@ -import { type ReactNode, useCallback, useMemo } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import type { EnhancedNoteStorage, @@ -8,6 +14,11 @@ import type { TemplateStorage, } from "@hypr/store"; +import { + ensureSessionContentLoaded, + isSessionContentLoaded, + isSessionContentLoading, +} from "../store/tinybase/persister/session/ops"; import * as main from "../store/tinybase/store/main"; export function useSession(sessionId: string) { @@ -186,6 +197,35 @@ export function useTemplate(templateId: string) { ); } +export function useSessionContentLoader(sessionId: string) { + const [isLoading, setIsLoading] = useState(() => + isSessionContentLoading(sessionId), + ); + const [isLoaded, setIsLoaded] = useState(() => + isSessionContentLoaded(sessionId), + ); + + useEffect(() => { + if (!sessionId) return; + + const loaded = isSessionContentLoaded(sessionId); + const loading = isSessionContentLoading(sessionId); + + setIsLoaded(loaded); + setIsLoading(loading); + + if (!loaded && !loading) { + setIsLoading(true); + ensureSessionContentLoaded(sessionId).then((success) => { + setIsLoaded(true); + setIsLoading(false); + }); + } + }, [sessionId]); + + return { isLoading, isLoaded }; +} + interface TinyBaseTestWrapperProps { children: ReactNode; initialData?: { diff --git a/apps/desktop/src/store/tinybase/persister/session/index.ts b/apps/desktop/src/store/tinybase/persister/session/index.ts index 3878c0242f..ee65ee030c 100644 --- a/apps/desktop/src/store/tinybase/persister/session/index.ts +++ b/apps/desktop/src/store/tinybase/persister/session/index.ts @@ -4,7 +4,7 @@ import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import type { Schemas } from "@hypr/store"; import type { Store } from "../../store/main"; -import { initSessionOps } from "./ops"; +import { clearContentLoadState, initSessionOps } from "./ops"; import { createSessionPersister } from "./persister"; const { useCreatePersister } = _UI as _UI.WithSchemas; @@ -23,6 +23,7 @@ export function useSessionPersister(store: Store) { initSessionOps({ store: store as Store, reloadSessions: async () => { + clearContentLoadState(); await persister.load(); }, }); diff --git a/apps/desktop/src/store/tinybase/persister/session/load/index.ts b/apps/desktop/src/store/tinybase/persister/session/load/index.ts index d2fd0ddf1f..268097db15 100644 --- a/apps/desktop/src/store/tinybase/persister/session/load/index.ts +++ b/apps/desktop/src/store/tinybase/persister/session/load/index.ts @@ -23,9 +23,12 @@ export { createEmptyLoadedSessionData, type LoadedSessionData } from "./types"; const LABEL = "SessionPersister"; +export type LoadMode = "metadata" | "full"; + async function processFiles( files: Partial>, result: LoadedSessionData, + mode: LoadMode = "full", ): Promise { for (const [path, content] of Object.entries(files)) { if (!content) continue; @@ -34,6 +37,10 @@ async function processFiles( } } + if (mode === "metadata") { + return; + } + for (const [path, content] of Object.entries(files)) { if (!content) continue; if (path.endsWith(SESSION_TRANSCRIPT_FILE)) { @@ -53,13 +60,23 @@ async function processFiles( export async function loadAllSessionData( dataDir: string, + mode: LoadMode = "full", ): Promise> { const result = createEmptyLoadedSessionData(); const sessionsDir = [dataDir, "sessions"].join(sep()); + const patterns = + mode === "metadata" + ? [SESSION_META_FILE] + : [ + SESSION_META_FILE, + SESSION_TRANSCRIPT_FILE, + `*${SESSION_NOTE_EXTENSION}`, + ]; + const scanResult = await fsSyncCommands.scanAndRead( sessionsDir, - [SESSION_META_FILE, SESSION_TRANSCRIPT_FILE, `*${SESSION_NOTE_EXTENSION}`], + patterns, true, null, ); @@ -72,20 +89,30 @@ export async function loadAllSessionData( return err(scanResult.error); } - await processFiles(scanResult.data.files, result); + await processFiles(scanResult.data.files, result, mode); return ok(result); } export async function loadSingleSession( dataDir: string, sessionId: string, + mode: LoadMode = "full", ): Promise> { const result = createEmptyLoadedSessionData(); const sessionsDir = [dataDir, "sessions"].join(sep()); + const patterns = + mode === "metadata" + ? [SESSION_META_FILE] + : [ + SESSION_META_FILE, + SESSION_TRANSCRIPT_FILE, + `*${SESSION_NOTE_EXTENSION}`, + ]; + const scanResult = await fsSyncCommands.scanAndRead( sessionsDir, - [SESSION_META_FILE, SESSION_TRANSCRIPT_FILE, `*${SESSION_NOTE_EXTENSION}`], + patterns, true, `/${sessionId}/`, ); @@ -98,6 +125,50 @@ export async function loadSingleSession( return err(scanResult.error); } - await processFiles(scanResult.data.files, result); + await processFiles(scanResult.data.files, result, mode); + return ok(result); +} + +export async function loadSessionContent( + dataDir: string, + sessionId: string, +): Promise> { + const result = createEmptyLoadedSessionData(); + const sessionsDir = [dataDir, "sessions"].join(sep()); + + const scanResult = await fsSyncCommands.scanAndRead( + sessionsDir, + [SESSION_TRANSCRIPT_FILE, `*${SESSION_NOTE_EXTENSION}`], + true, + `/${sessionId}/`, + ); + + if (scanResult.status === "error") { + if (isDirectoryNotFoundError(scanResult.error)) { + return ok(result); + } + console.error( + `[${LABEL}] loadSessionContent scan error:`, + scanResult.error, + ); + return err(scanResult.error); + } + + for (const [path, content] of Object.entries(scanResult.data.files)) { + if (!content) continue; + if (path.endsWith(SESSION_TRANSCRIPT_FILE)) { + processTranscriptFile(path, content, result); + } + } + + const mdPromises: Promise[] = []; + for (const [path, content] of Object.entries(scanResult.data.files)) { + if (!content) continue; + if (path.endsWith(SESSION_NOTE_EXTENSION)) { + mdPromises.push(processMdFile(path, content, result)); + } + } + await Promise.all(mdPromises); + return ok(result); } diff --git a/apps/desktop/src/store/tinybase/persister/session/ops.ts b/apps/desktop/src/store/tinybase/persister/session/ops.ts index 95326d9f7e..28b04040f9 100644 --- a/apps/desktop/src/store/tinybase/persister/session/ops.ts +++ b/apps/desktop/src/store/tinybase/persister/session/ops.ts @@ -1,6 +1,8 @@ import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync"; import type { Store } from "../../store/main"; +import { getDataDir } from "../shared"; +import { loadSessionContent } from "./load/index"; export interface SessionOpsConfig { store: Store; @@ -9,10 +11,33 @@ export interface SessionOpsConfig { let config: SessionOpsConfig | null = null; +const contentLoadState = { + loaded: new Set(), + loading: new Set(), +}; + export function initSessionOps(cfg: SessionOpsConfig) { config = cfg; } +export function clearContentLoadState() { + contentLoadState.loaded.clear(); + contentLoadState.loading.clear(); +} + +export function isSessionContentLoaded(sessionId: string): boolean { + return contentLoadState.loaded.has(sessionId); +} + +export function isSessionContentLoading(sessionId: string): boolean { + return contentLoadState.loading.has(sessionId); +} + +export function markSessionContentLoaded(sessionId: string) { + contentLoadState.loaded.add(sessionId); + contentLoadState.loading.delete(sessionId); +} + function getConfig(): SessionOpsConfig { if (!config) { throw new Error("[SessionOps] Not initialized. Call initSessionOps first."); @@ -20,6 +45,76 @@ function getConfig(): SessionOpsConfig { return config; } +export async function ensureSessionContentLoaded( + sessionId: string, +): Promise { + if (contentLoadState.loaded.has(sessionId)) { + return true; + } + + if (contentLoadState.loading.has(sessionId)) { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (contentLoadState.loaded.has(sessionId)) { + clearInterval(checkInterval); + resolve(true); + } + if (!contentLoadState.loading.has(sessionId)) { + clearInterval(checkInterval); + resolve(false); + } + }, 50); + }); + } + + contentLoadState.loading.add(sessionId); + + try { + const { store } = getConfig(); + const dataDir = await getDataDir(); + const result = await loadSessionContent(dataDir, sessionId); + + if (result.status === "error") { + console.error( + `[SessionOps] Failed to load content for session ${sessionId}:`, + result.error, + ); + contentLoadState.loading.delete(sessionId); + contentLoadState.loaded.add(sessionId); + return false; + } + + store.transaction(() => { + for (const [transcriptId, transcript] of Object.entries( + result.data.transcripts, + )) { + store.setRow("transcripts", transcriptId, transcript); + } + + for (const [noteId, note] of Object.entries(result.data.enhanced_notes)) { + store.setRow("enhanced_notes", noteId, note); + } + + const session = result.data.sessions[sessionId]; + if (session?.raw_md) { + store.setCell("sessions", sessionId, "raw_md", session.raw_md); + } + }); + + contentLoadState.loading.delete(sessionId); + contentLoadState.loaded.add(sessionId); + return true; + } catch (error) { + console.error( + `[SessionOps] Error loading content for session ${sessionId}:`, + error, + ); + contentLoadState.loading.delete(sessionId); + contentLoadState.loaded.add(sessionId); + return false; + } +} + export async function moveSessionToFolder( sessionId: string, targetFolderId: string, diff --git a/apps/desktop/src/store/tinybase/persister/session/persister.ts b/apps/desktop/src/store/tinybase/persister/session/persister.ts index 854afc845d..dc064c2dd6 100644 --- a/apps/desktop/src/store/tinybase/persister/session/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/session/persister.ts @@ -43,7 +43,7 @@ export function createSessionPersister(store: Store) { keepIds: Object.keys(tables.enhanced_notes ?? {}), }, ], - loadAll: loadAllSessionData, + loadAll: (dataDir) => loadAllSessionData(dataDir, "metadata"), loadSingle: loadSingleSession, save: (store, tables, dataDir, changedTables) => { let changedSessionIds: Set | undefined; diff --git a/apps/desktop/src/store/tinybase/store/sessions.ts b/apps/desktop/src/store/tinybase/store/sessions.ts index f8a6626577..75d76473d1 100644 --- a/apps/desktop/src/store/tinybase/store/sessions.ts +++ b/apps/desktop/src/store/tinybase/store/sessions.ts @@ -2,6 +2,7 @@ import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { DEFAULT_USER_ID } from "../../../utils"; import { id } from "../../../utils"; +import { markSessionContentLoaded } from "../persister/session/ops"; import * as main from "./main"; type Store = NonNullable>; @@ -14,6 +15,7 @@ export function createSession(store: Store, title?: string): string { raw_md: "", user_id: DEFAULT_USER_ID, }); + markSessionContentLoaded(sessionId); void analyticsCommands.event({ event: "note_created", has_event_id: false, @@ -47,6 +49,7 @@ export function getOrCreateSessionForEventId( raw_md: "", user_id: DEFAULT_USER_ID, }); + markSessionContentLoaded(sessionId); void analyticsCommands.event({ event: "note_created", has_event_id: true, From 82b3a6276b6b995eb8ff67f8fd0ceef1bd94c269 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:36:31 +0000 Subject: [PATCH 2/2] fix: remove unused variable in useSessionContentLoader hook Co-Authored-By: yujonglee --- apps/desktop/src/hooks/tinybase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/hooks/tinybase.tsx b/apps/desktop/src/hooks/tinybase.tsx index c9855dcdfb..c62a049c9d 100644 --- a/apps/desktop/src/hooks/tinybase.tsx +++ b/apps/desktop/src/hooks/tinybase.tsx @@ -216,7 +216,7 @@ export function useSessionContentLoader(sessionId: string) { if (!loaded && !loading) { setIsLoading(true); - ensureSessionContentLoaded(sessionId).then((success) => { + ensureSessionContentLoaded(sessionId).then(() => { setIsLoaded(true); setIsLoading(false); });