Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/desktop/src/components/main/body/sessions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -85,12 +86,21 @@ export function TabContentNote({
}: {
tab: Extract<Tab, { type: "sessions" }>;
}) {
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 (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-muted-foreground">Loading session...</div>
</div>
);
}
Comment on lines +96 to +102
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 React hooks called after conditional early return

The TabContentNote component has an early return at lines 96-102 that occurs before hooks are called, violating React's rules of hooks.

Click to expand

Problem

In TabContentNote, hooks (useEffect at line 104 and useQuery at line 135) are called after a conditional early return:

if (contentLoading) {
  return (
    <div className="flex items-center justify-center h-full">
      <div className="text-sm text-muted-foreground">Loading session...</div>
    </div>
  );
}

useEffect(() => { // This hook is called conditionally!
  ...
}, [...]);

React requires hooks to be called in the same order on every render. When contentLoading is true, the hooks after the return statement are not called, but when contentLoading becomes false, they suddenly are. This can cause:

  • React errors/warnings about hooks being called in a different order
  • Unpredictable behavior and potential crashes
  • State inconsistencies

Fix

Move all hooks before the early return, or move the loading check into the JSX return.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


useEffect(() => {
if (!tab.state.autoStart) {
hasAttemptedAutoStart.current = false;
Expand Down
42 changes: 41 additions & 1 deletion apps/desktop/src/hooks/tinybase.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { type ReactNode, useCallback, useMemo } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";

import type {
EnhancedNoteStorage,
Expand All @@ -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) {
Expand Down Expand Up @@ -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(() => {
setIsLoaded(true);
setIsLoading(false);
});
}
}, [sessionId]);

return { isLoading, isLoaded };
}

interface TinyBaseTestWrapperProps {
children: ReactNode;
initialData?: {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/store/tinybase/persister/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schemas>;
Expand All @@ -23,6 +23,7 @@ export function useSessionPersister(store: Store) {
initSessionOps({
store: store as Store,
reloadSessions: async () => {
clearContentLoadState();
await persister.load();
},
});
Expand Down
79 changes: 75 additions & 4 deletions apps/desktop/src/store/tinybase/persister/session/load/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ export { createEmptyLoadedSessionData, type LoadedSessionData } from "./types";

const LABEL = "SessionPersister";

export type LoadMode = "metadata" | "full";

async function processFiles(
files: Partial<Record<string, string>>,
result: LoadedSessionData,
mode: LoadMode = "full",
): Promise<void> {
for (const [path, content] of Object.entries(files)) {
if (!content) continue;
Expand All @@ -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)) {
Expand All @@ -53,13 +60,23 @@ async function processFiles(

export async function loadAllSessionData(
dataDir: string,
mode: LoadMode = "full",
): Promise<LoadResult<LoadedSessionData>> {
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,
);
Expand All @@ -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<LoadResult<LoadedSessionData>> {
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}/`,
);
Expand All @@ -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<LoadResult<LoadedSessionData>> {
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<void>[] = [];
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);
}
Comment on lines +132 to 174
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Session raw_md content is never loaded during lazy loading

The loadSessionContent function fails to load raw_md content from _memo.md files because result.sessions is empty when processing note files.

Click to expand

Problem

In loadSessionContent (apps/desktop/src/store/tinybase/persister/session/load/index.ts:132-174):

  1. It creates an empty result with result.sessions = {}
  2. It scans for *.md files including _memo.md
  3. When processMdFile processes _memo.md, it checks if (result.sessions[fm.session_id]) at note.ts:37 before setting raw_md
  4. Since no meta file is loaded, result.sessions is empty, so the check fails and raw_md is never set
// In note.ts:36-39
if (path.endsWith(SESSION_MEMO_FILE)) {
  if (result.sessions[fm.session_id]) {  // Always false since sessions is empty!
    result.sessions[fm.session_id].raw_md = tiptapContent;
  }
}

Then in ops.ts:98-101, the code tries to use this never-populated data:

const session = result.data.sessions[sessionId]; // undefined
if (session?.raw_md) {
  store.setCell("sessions", sessionId, "raw_md", session.raw_md);
}

Impact

User notes stored in _memo.md files will not be loaded when opening existing sessions. Users will see empty notes even though they have saved content.

Fix

In loadSessionContent, initialize result.sessions[sessionId] before processing note files, or change processMdFile to create the session entry if it doesn't exist for memo files.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

95 changes: 95 additions & 0 deletions apps/desktop/src/store/tinybase/persister/session/ops.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,17 +11,110 @@ export interface SessionOpsConfig {

let config: SessionOpsConfig | null = null;

const contentLoadState = {
loaded: new Set<string>(),
loading: new Set<string>(),
};

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.");
}
return config;
}

export async function ensureSessionContentLoaded(
sessionId: string,
): Promise<boolean> {
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;
Comment on lines +77 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling breaks retry logic: When content loading fails, the session is still marked as loaded (line 83). This prevents any future retry attempts, permanently leaving the session without its content.

if (result.status === "error") {
  console.error(
    `[SessionOps] Failed to load content for session ${sessionId}:`,
    result.error,
  );
  contentLoadState.loading.delete(sessionId);
  // Don't mark as loaded on error - allow retry
  return false;
}

Remove line 83 (contentLoadState.loaded.add(sessionId)) from the error handler to allow retries on subsequent session opens.

Suggested change
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;
if (result.status === "error") {
console.error(
`[SessionOps] Failed to load content for session ${sessionId}:`,
result.error,
);
contentLoadState.loading.delete(sessionId);
return false;

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

}

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined;
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/store/tinybase/store/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof main.UI.useStore>>;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down