Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions apps/desktop/src/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,8 @@ import { useStartListening } from "~/stt/useStartListening";
import { useSTTConnection } from "~/stt/useSTTConnection";
import { useUploadFile } from "~/stt/useUploadFile";

const hydratedSessionIds = new Set<string>();

export function TabContentNote({
standaloneWindow = false,
tab,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -222,20 +226,79 @@ function TabContentNoteInner({
</div>
) : null}
<div className="min-h-0 flex-1">
<NoteInput
ref={noteInputRef}
tab={tab}
editorTabs={editorTabs}
currentTab={currentView}
handleTabChange={handleTabChange}
hideHeader
/>
{contentHydrated ? (
<NoteInput
ref={noteInputRef}
tab={tab}
editorTabs={editorTabs}
currentTab={currentView}
handleTabChange={handleTabChange}
hideHeader
/>
) : (
<SessionContentLoading />
)}
</div>
</div>
</SessionSurface>
);
}

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);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
});

return () => {
active = false;
};
}, [store, sessionId, retryAttempt]);

return hydrated;
}

function SessionContentLoading() {
return (
<div className="flex h-full flex-col gap-3 px-4 py-5">
<div className="bg-muted h-5 w-3/5 animate-pulse rounded-md" />
<div className="bg-muted/80 h-4 w-4/5 animate-pulse rounded-md" />
<div className="bg-muted/70 h-4 w-2/3 animate-pulse rounded-md" />
</div>
);
}

function usePendingUpload(sessionId: string) {
const { processFile } = useUploadFile(sessionId);
const processFileRef = useRef(processFile);
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/store/tinybase/persister/session/hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
36 changes: 36 additions & 0 deletions apps/desktop/src/store/tinybase/persister/session/hydrate.ts
Original file line number Diff line number Diff line change
@@ -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<Schemas>,
sessionId: string,
): Promise<boolean> {
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;
}
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
21 changes: 19 additions & 2 deletions apps/desktop/src/store/tinybase/persister/session/load/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ export { createEmptyLoadedSessionData, type LoadedSessionData } from "./types";

const LABEL = "SessionPersister";

type LoadSessionDataOptions = {
includeContent?: boolean;
};

async function processFiles(
files: Partial<Record<string, string>>,
result: LoadedSessionData,
{ includeContent = true }: LoadSessionDataOptions = {},
): Promise<void> {
for (const [path, content] of Object.entries(files)) {
if (!content) continue;
Expand All @@ -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)) {
Expand All @@ -54,13 +63,21 @@ async function processFiles(

export async function loadAllSessionData(
dataDir: string,
options: LoadSessionDataOptions = {},
): Promise<LoadResult<LoadedSessionData>> {
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,
);
Expand All @@ -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);
}

Expand Down
32 changes: 30 additions & 2 deletions apps/desktop/src/store/tinybase/persister/session/persister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Comment thread
cursor[bot] marked this conversation as resolved.
loadSingle: loadSingleSession,
save: (store, tables, dataDir, changedTables) => {
let changedSessionIds: Set<string> | undefined;
Expand All @@ -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 {
Expand All @@ -68,3 +73,26 @@ export function createSessionPersister(store: Store) {
},
});
}

function hasSessionRawContentChange(
changedTables: Parameters<typeof getChangedSessionIds>[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")
);
});
}
Loading
Loading