From 0d5b805f7bdcb568d5c820b1c743fb41bb8291ef Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:07:40 +0900 Subject: [PATCH] Load session metadata before note content Start the session persister with metadata-only scans and hydrate full session content lazily when a note opens. --- apps/desktop/src/session/index.tsx | 79 +++++++++++++++++-- .../persister/session/hydrate.test.ts | 66 ++++++++++++++++ .../tinybase/persister/session/hydrate.ts | 36 +++++++++ .../persister/session/load/index.test.ts | 44 +++++++++++ .../tinybase/persister/session/load/index.ts | 21 ++++- .../tinybase/persister/session/persister.ts | 32 +++++++- .../persister/session/save/note.test.ts | 60 ++++++++++++++ .../tinybase/persister/session/save/note.ts | 16 +++- 8 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/store/tinybase/persister/session/hydrate.test.ts create mode 100644 apps/desktop/src/store/tinybase/persister/session/hydrate.ts create mode 100644 apps/desktop/src/store/tinybase/persister/session/load/index.test.ts create mode 100644 apps/desktop/src/store/tinybase/persister/session/save/note.test.ts diff --git a/apps/desktop/src/session/index.tsx b/apps/desktop/src/session/index.tsx index 3d663da3e2..8e069c0057 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -21,6 +21,7 @@ import { useAutoEnhance } from "./hooks/useAutoEnhance"; import { shouldShowSessionTopAudioPlayer } from "./top-audio-player"; import * as AudioPlayer from "~/audio-player"; +import { hydrateSessionContent } from "~/store/tinybase/persister/session/hydrate"; import * as main from "~/store/tinybase/store/main"; import { type Tab, useTabs } from "~/store/zustand/tabs"; import { useListener } from "~/stt/contexts"; @@ -29,6 +30,8 @@ import { useStartListening } from "~/stt/useStartListening"; import { useSTTConnection } from "~/stt/useSTTConnection"; import { useUploadFile } from "~/stt/useUploadFile"; +const hydratedSessionIds = new Set(); + export function TabContentNote({ standaloneWindow = false, tab, @@ -123,6 +126,7 @@ function TabContentNoteInner({ const { audioExists } = AudioPlayer.useAudioPlayer(); const editorTabs = useEditorTabs({ sessionId: tab.id, audioExists }); const currentView = useCurrentNoteTab(tab, { audioExists }); + const contentHydrated = useHydrateSessionContent(tab.id); const updateSessionTabState = useTabs((state) => state.updateSessionTabState); const hasTranscript = useHasTranscript(tab.id); @@ -222,20 +226,79 @@ function TabContentNoteInner({ ) : null}
- + {contentHydrated ? ( + + ) : ( + + )}
); } +function useHydrateSessionContent(sessionId: string): boolean { + const store = main.UI.useStore(main.STORE_ID); + const [retryAttempt, setRetryAttempt] = React.useState(0); + const [hydrated, setHydrated] = React.useState(() => + hydratedSessionIds.has(sessionId), + ); + + useEffect(() => { + if (hydratedSessionIds.has(sessionId)) { + setHydrated(true); + return; + } + + if (!store) { + setHydrated(false); + return; + } + + let active = true; + setHydrated(false); + + void hydrateSessionContent(store, sessionId).then((success) => { + if (success) { + hydratedSessionIds.add(sessionId); + } + if (active) { + setHydrated(success); + if (!success) { + window.setTimeout(() => { + if (active) { + setRetryAttempt((attempt) => attempt + 1); + } + }, 1000); + } + } + }); + + return () => { + active = false; + }; + }, [store, sessionId, retryAttempt]); + + return hydrated; +} + +function SessionContentLoading() { + return ( +
+
+
+
+
+ ); +} + function usePendingUpload(sessionId: string) { const { processFile } = useUploadFile(sessionId); const processFileRef = useRef(processFile); diff --git a/apps/desktop/src/store/tinybase/persister/session/hydrate.test.ts b/apps/desktop/src/store/tinybase/persister/session/hydrate.test.ts new file mode 100644 index 0000000000..c1cbbd554d --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session/hydrate.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { hydrateSessionContent } from "./hydrate"; +import { loadSingleSession } from "./load"; + +import { ok } from "~/store/tinybase/persister/shared"; +import { createTestMainStore } from "~/store/tinybase/persister/testing/mocks"; + +vi.mock("./load", () => ({ + loadSingleSession: vi.fn(), +})); + +vi.mock("~/store/tinybase/persister/shared/paths", () => ({ + getDataDir: vi.fn().mockResolvedValue("/data"), +})); + +const loadSingleSessionMock = vi.mocked(loadSingleSession); + +describe("hydrateSessionContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("merges loaded session content without removing other sessions", async () => { + const store = createTestMainStore(); + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "First", + raw_md: "", + }); + store.setRow("sessions", "session-2", { + user_id: "user-1", + created_at: "2024-01-02T00:00:00Z", + title: "Second", + raw_md: "", + }); + + loadSingleSessionMock.mockResolvedValue( + ok({ + sessions: { + "session-1": { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "First", + raw_md: '{"type":"doc"}', + }, + }, + mapping_session_participant: {}, + tags: {}, + mapping_tag_session: {}, + transcripts: {}, + enhanced_notes: {}, + session_key_facts: {}, + }), + ); + + await hydrateSessionContent(store, "session-1"); + + expect(store.getRowIds("sessions")).toEqual(["session-1", "session-2"]); + expect(store.getCell("sessions", "session-1", "raw_md")).toBe( + '{"type":"doc"}', + ); + expect(store.getCell("sessions", "session-2", "title")).toBe("Second"); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/session/hydrate.ts b/apps/desktop/src/store/tinybase/persister/session/hydrate.ts new file mode 100644 index 0000000000..e509693583 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session/hydrate.ts @@ -0,0 +1,36 @@ +import type { Store } from "tinybase/with-schemas"; + +import type { Schemas } from "@hypr/store"; +import { asTablesChanges } from "@hypr/tinybase-utils"; + +import { loadSingleSession } from "./load"; +import type { LoadedSessionData } from "./load"; + +import { getDataDir } from "~/store/tinybase/persister/shared/paths"; + +function hasLoadedRows(data: LoadedSessionData): boolean { + return Object.values(data).some((table) => Object.keys(table).length > 0); +} + +export async function hydrateSessionContent( + store: Store, + sessionId: string, +): Promise { + const dataDir = await getDataDir(); + const loadResult = await loadSingleSession(dataDir, sessionId); + + if (loadResult.status === "error") { + console.error( + `[SessionPersister] hydrate error for ${sessionId}:`, + loadResult.error, + ); + return false; + } + + if (!hasLoadedRows(loadResult.data)) { + return true; + } + + store.applyChanges(asTablesChanges(loadResult.data)); + return true; +} diff --git a/apps/desktop/src/store/tinybase/persister/session/load/index.test.ts b/apps/desktop/src/store/tinybase/persister/session/load/index.test.ts new file mode 100644 index 0000000000..2f2b8f5ff9 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session/load/index.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { loadAllSessionData } from "./index"; + +const fsSyncMocks = vi.hoisted(() => ({ + scanAndRead: vi.fn(), +})); + +vi.mock("@tauri-apps/api/path", () => ({ + sep: () => "/", +})); +vi.mock("@hypr/plugin-fs-sync", () => ({ commands: fsSyncMocks })); + +describe("loadAllSessionData", () => { + beforeEach(() => { + vi.clearAllMocks(); + fsSyncMocks.scanAndRead.mockResolvedValue({ + status: "ok", + data: { files: {}, dirs: [] }, + }); + }); + + test("scans only metadata when content is excluded", async () => { + await loadAllSessionData("/data", { includeContent: false }); + + expect(fsSyncMocks.scanAndRead).toHaveBeenCalledWith( + "/data/sessions", + ["_meta.json"], + true, + null, + ); + }); + + test("scans metadata and content by default", async () => { + await loadAllSessionData("/data"); + + expect(fsSyncMocks.scanAndRead).toHaveBeenCalledWith( + "/data/sessions", + ["_meta.json", "transcript.json", "*.md"], + true, + null, + ); + }); +}); 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 717a801863..99e24eb3b6 100644 --- a/apps/desktop/src/store/tinybase/persister/session/load/index.ts +++ b/apps/desktop/src/store/tinybase/persister/session/load/index.ts @@ -24,9 +24,14 @@ export { createEmptyLoadedSessionData, type LoadedSessionData } from "./types"; const LABEL = "SessionPersister"; +type LoadSessionDataOptions = { + includeContent?: boolean; +}; + async function processFiles( files: Partial>, result: LoadedSessionData, + { includeContent = true }: LoadSessionDataOptions = {}, ): Promise { for (const [path, content] of Object.entries(files)) { if (!content) continue; @@ -35,6 +40,10 @@ async function processFiles( } } + if (!includeContent) { + return; + } + for (const [path, content] of Object.entries(files)) { if (!content) continue; if (path.endsWith(SESSION_TRANSCRIPT_FILE)) { @@ -54,13 +63,21 @@ async function processFiles( export async function loadAllSessionData( dataDir: string, + options: LoadSessionDataOptions = {}, ): Promise> { const result = createEmptyLoadedSessionData(); const sessionsDir = [dataDir, "sessions"].join(sep()); + const includeContent = options.includeContent ?? true; const scanResult = await fsSyncCommands.scanAndRead( sessionsDir, - [SESSION_META_FILE, SESSION_TRANSCRIPT_FILE, `*${SESSION_NOTE_EXTENSION}`], + includeContent + ? [ + SESSION_META_FILE, + SESSION_TRANSCRIPT_FILE, + `*${SESSION_NOTE_EXTENSION}`, + ] + : [SESSION_META_FILE], true, null, ); @@ -73,7 +90,7 @@ export async function loadAllSessionData( return err(scanResult.error); } - await processFiles(scanResult.data.files, result); + await processFiles(scanResult.data.files, result, { includeContent }); return ok(result); } diff --git a/apps/desktop/src/store/tinybase/persister/session/persister.ts b/apps/desktop/src/store/tinybase/persister/session/persister.ts index 33a9f9f285..50d0994cb4 100644 --- a/apps/desktop/src/store/tinybase/persister/session/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/session/persister.ts @@ -33,7 +33,8 @@ export function createSessionPersister(store: Store) { { tableName: "enhanced_notes", foreignKey: "session_id" }, { tableName: "session_key_facts", foreignKey: "session_id" }, ], - loadAll: loadAllSessionData, + loadAll: (dataDir) => + loadAllSessionData(dataDir, { includeContent: false }), loadSingle: loadSingleSession, save: (store, tables, dataDir, changedTables) => { let changedSessionIds: Set | undefined; @@ -58,8 +59,12 @@ export function createSessionPersister(store: Store) { const transcriptOps = saveScope.transcript ? buildTranscriptSaveOps(tables, dataDir, changedSessionIds) : []; + const deleteEmptyMemos = + !changedTables || hasSessionRawContentChange(changedTables); const noteOps = saveScope.note - ? buildNoteSaveOps(store, tables, dataDir, changedSessionIds) + ? buildNoteSaveOps(store, tables, dataDir, changedSessionIds, { + deleteEmptyMemos, + }) : []; return { @@ -68,3 +73,26 @@ export function createSessionPersister(store: Store) { }, }); } + +function hasSessionRawContentChange( + changedTables: Parameters[1], +) { + const changedSessions = changedTables.sessions; + if (!changedSessions) { + return false; + } + + return Object.values(changedSessions).some((rowChange) => { + const row = + Array.isArray(rowChange) && rowChange.length > 0 + ? rowChange[0] + : rowChange; + + return ( + !!row && + typeof row === "object" && + !Array.isArray(row) && + Object.prototype.hasOwnProperty.call(row, "raw_md") + ); + }); +} diff --git a/apps/desktop/src/store/tinybase/persister/session/save/note.test.ts b/apps/desktop/src/store/tinybase/persister/session/save/note.test.ts new file mode 100644 index 0000000000..f7220aea60 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session/save/note.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, vi } from "vitest"; + +import { buildNoteSaveOps } from "./note"; + +import { createTestMainStore } from "~/store/tinybase/persister/testing/mocks"; + +vi.mock("@tauri-apps/api/path", () => ({ + sep: () => "/", +})); + +describe("buildNoteSaveOps", () => { + test("does not delete empty memos when folder-only changes are saved", () => { + const store = createTestMainStore(); + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + folder_id: "work", + event_json: "", + raw_md: "", + }); + + const ops = buildNoteSaveOps( + store, + store.getTables(), + "/data", + new Set(["session-1"]), + { deleteEmptyMemos: false }, + ); + + expect(ops).toEqual([]); + }); + + test("deletes empty memos when note content is cleared", () => { + const store = createTestMainStore(); + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + folder_id: "work", + event_json: "", + raw_md: "", + }); + + const ops = buildNoteSaveOps( + store, + store.getTables(), + "/data", + new Set(["session-1"]), + { deleteEmptyMemos: true }, + ); + + expect(ops).toEqual([ + { + type: "delete", + paths: ["/data/sessions/work/session-1/_memo.md"], + }, + ]); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/session/save/note.ts b/apps/desktop/src/store/tinybase/persister/session/save/note.ts index 608d4c3602..68472d92dd 100644 --- a/apps/desktop/src/store/tinybase/persister/session/save/note.ts +++ b/apps/desktop/src/store/tinybase/persister/session/save/note.ts @@ -21,6 +21,7 @@ type BuildContext = { tables: TablesContent; dataDir: string; changedSessionIds?: Set; + deleteEmptyMemos: boolean; }; export function buildNoteSaveOps( @@ -28,8 +29,15 @@ export function buildNoteSaveOps( tables: TablesContent, dataDir: string, changedSessionIds?: Set, + options: { deleteEmptyMemos?: boolean } = {}, ): WriteOperation[] { - const ctx: BuildContext = { store, tables, dataDir, changedSessionIds }; + const ctx: BuildContext = { + store, + tables, + dataDir, + changedSessionIds, + deleteEmptyMemos: options.deleteEmptyMemos ?? true, + }; const enhancedNoteItems = collectEnhancedNotes(ctx); const { items: memoItems, deletePaths: memoDeletePaths } = collectMemos(ctx); @@ -74,7 +82,7 @@ function collectMemos(ctx: BuildContext): { items: DocumentItem[]; deletePaths: string[]; } { - const { tables, dataDir, changedSessionIds } = ctx; + const { tables, dataDir, changedSessionIds, deleteEmptyMemos } = ctx; const items: DocumentItem[] = []; const deletePaths: string[] = []; @@ -92,7 +100,9 @@ function collectMemos(ctx: BuildContext): { ? tryParseAndConvertToMarkdown(session.raw_md) : null; if (!markdown) { - deletePaths.push(memoPath); + if (deleteEmptyMemos) { + deletePaths.push(memoPath); + } continue; }