From afa78d08c39e78eb51daf83483769b3471651e8c Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Fri, 4 Apr 2025 19:17:02 -0400 Subject: [PATCH 01/12] chore: migrate to use axios Signed-off-by: Aaron Pham --- packages/morph/context/providers.tsx | 9 +- packages/morph/lib/index.ts | 16 ++ packages/morph/package.json | 1 + packages/morph/services/agents.ts | 29 +-- packages/morph/services/authors.ts | 167 ++++++++---------- .../morph/services/context-aware-notes.ts | 31 ---- packages/morph/services/essays.ts | 134 ++++++-------- packages/morph/services/notes.ts | 138 ++++++--------- pnpm-lock.yaml | 60 +++++++ 9 files changed, 279 insertions(+), 306 deletions(-) diff --git a/packages/morph/context/providers.tsx b/packages/morph/context/providers.tsx index 8718249..73a1273 100644 --- a/packages/morph/context/providers.tsx +++ b/packages/morph/context/providers.tsx @@ -3,6 +3,7 @@ import { applyPgLiteMigrations, initializeDb } from "@/db" import migrations from "@/generated/migrations.json" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { AnimatePresence, motion } from "motion/react" import type React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -10,13 +11,13 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import PixelatedLoading from "@/components/landing/pixelated-loading" import { TooltipProvider } from "@/components/ui/tooltip" +import { AuthorTasksProvider } from "@/context/authors" import { MorphPgLite, PGliteProvider } from "@/context/db" import { EmbeddingProvider } from "@/context/embedding" import { FileRestorationProvider, useRestoredFile } from "@/context/file-restoration" import { ThemeProvider } from "@/context/theme" import { VaultProvider } from "@/context/vault" import { verifyHandle } from "@/context/vault-reducer" -import { AuthorTasksProvider } from "@/context/authors" import useFsHandles from "@/hooks/use-fs-handles" import { SettingsProvider } from "@/hooks/use-persisted-settings" @@ -220,6 +221,12 @@ export default memo(function ClientProvider({ children }: ClientProviderProps) { + diff --git a/packages/morph/lib/index.ts b/packages/morph/lib/index.ts index b5cf792..44c5f53 100644 --- a/packages/morph/lib/index.ts +++ b/packages/morph/lib/index.ts @@ -195,3 +195,19 @@ export function formatDateString(dateStr: string): FormattedDateResult { relativeTime, } } + +export function safeDate(dateStr: string | null | undefined): Date | null { + if (!dateStr) return null + + try { + const date = new Date(dateStr) + // Check if date is valid + if (isNaN(date.getTime())) { + return null + } + return date + } catch (error) { + console.error(`Failed to parse date string: ${dateStr}`, error) + return null + } +} diff --git a/packages/morph/package.json b/packages/morph/package.json index 7fafaf5..4bea9b5 100644 --- a/packages/morph/package.json +++ b/packages/morph/package.json @@ -58,6 +58,7 @@ "@uiw/codemirror-extensions-hyper-link": "^4.23.10", "@uiw/codemirror-themes": "^4.23.10", "@uiw/react-codemirror": "^4.23.10", + "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", diff --git a/packages/morph/services/agents.ts b/packages/morph/services/agents.ts index e2bc0ff..4c557b9 100644 --- a/packages/morph/services/agents.ts +++ b/packages/morph/services/agents.ts @@ -1,4 +1,5 @@ import { API_ENDPOINT } from "@/services/constants" +import axios, { AxiosResponse } from "axios" // Interfaces export interface SuggestionRequest { @@ -59,22 +60,26 @@ export interface StreamingCallbacks { * Checks if the agent API is available */ export async function checkAgentAvailability(): Promise { - try { - const readyz = await fetch(`${API_ENDPOINT}/readyz`) - return readyz.ok - } catch (error) { - console.error("Error checking agent availability:", error) - return false - } + return await axios + .get(`${API_ENDPOINT}/readyz`) + .then((resp) => resp.status === 200) + .catch((err) => { + console.error("Error checking agent availability:", err) + return false + }) } /** * Checks the health of the agent API */ export async function checkAgentHealth(timeout: number = 30): Promise { - return fetch(`${API_ENDPOINT}/health`, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ timeout }), - }).then((data) => data.json()) + return await axios + .post<{ timeout: number }, AxiosResponse>(`${API_ENDPOINT}/health`, { + timeout, + }) + .then((response) => response.data) + .catch((err) => { + console.error("Services aren't ready:", err) + throw err + }) } diff --git a/packages/morph/services/authors.ts b/packages/morph/services/authors.ts index f0c88c3..a2b6c6b 100644 --- a/packages/morph/services/authors.ts +++ b/packages/morph/services/authors.ts @@ -1,9 +1,12 @@ +import { safeDate } from "@/lib" import { API_ENDPOINT, POLLING_INTERVAL, TaskStatusResponse } from "@/services/constants" import { useMutation, useQuery } from "@tanstack/react-query" +import axios from "axios" import { eq } from "drizzle-orm" import { PgliteDatabase, drizzle } from "drizzle-orm/pglite" import { usePGlite } from "@/context/db" +import { DEFAULT_AUTHORS } from "@/context/steering" import * as schema from "@/db/schema" @@ -62,106 +65,78 @@ async function submitAuthorTask( max_tokens?: number use_tool?: boolean } = {}, -): Promise { - const req: AuthorRequest = { - essay: content, - num_authors: options.num_authors || 8, - temperature: options.temperature || 0.7, - max_tokens: options.max_tokens || 16384, - search_backend: "exa", +) { + return axios + .post(`${API_ENDPOINT}/authors/submit`, { + essay: content, + num_authors: options.num_authors || 8, + temperature: options.temperature || 0.7, + max_tokens: options.max_tokens || 16384, + search_backend: "exa", num_search_results: 3, use_tool: options.use_tool || false, - } - - const response = await fetch(`${API_ENDPOINT}/authors/submit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(req), }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[Authors] Failed to submit content for author recommendations:`, - error.error || response.statusText, - ) - throw new Error(`Failed to submit for author recommendations: ${error.error || response.statusText}`) - } - - const responseData = await response.json() - return responseData + .then((resp) => resp.data) + .catch((err) => { + console.error( + `[Authors] Failed to submit content for author recommendations:`, + err.response.data?.error || err.message, + ) + throw new Error( + `Failed to submit for author recommendations: ${err.response.data?.error || err.message}`, + ) + }) } // Check the status of an author task -async function checkAuthorTask(taskId: string): Promise { - try { - const response = await fetch(`${API_ENDPOINT}/authors/status?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) +async function checkAuthorTask(taskId: string) { + return await axios + .get(`${API_ENDPOINT}/authors/status`, { + params: { task_id: taskId }, + }) + .then((resp) => resp.data) + .catch((error) => { console.error( `[Authors] Status check failed for task ${taskId}:`, - error.error || response.statusText, + error.response.data?.error || error.message, ) - throw new Error(`Failed to check status: ${error.error || response.statusText}`) - } - - const statusData = await response.json() - return statusData - } catch (error) { - console.error(`[Authors] Error checking task status for ${taskId}:`, error) - // Return a failure status for any errors - return { - task_id: taskId, - status: "failure" as const, - created_at: new Date().toISOString(), - executed_at: new Date().toISOString(), - } - } + return { + task_id: taskId, + status: "failure" as const, + created_at: new Date().toISOString(), + executed_at: new Date().toISOString(), + } + }) } // Get author recommendations for a completed task -async function getAuthorTask(taskId: string): Promise { - const response = await fetch(`${API_ENDPOINT}/authors/get?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[Authors] Failed to get recommendations for task ${taskId}:`, - error.error || response.statusText, - ) - throw new Error(`Failed to get recommendations: ${error.error || response.statusText}`) - } - - const authorsData = await response.json() - - // Verify we received the correct type of response - if (!authorsData.authors || !Array.isArray(authorsData.authors)) { - console.error( - `[Authors] Invalid response format for task ${taskId}, missing authors array:`, - authorsData, - ) - throw new Error(`Invalid response format: missing authors array`) - } - - return authorsData -} - -// Add a helper function to safely create dates -function safeDate(dateStr: string | null | undefined): Date | null { - if (!dateStr) return null - - try { - const date = new Date(dateStr) - // Check if date is valid - if (isNaN(date.getTime())) { - return null - } - return date - } catch (error) { - console.error(`Failed to parse date string: ${dateStr}`, error) - return null - } +async function getAuthorTask(taskId: string) { + return await axios + .get(`${API_ENDPOINT}/authors/get`, { + params: { task_id: taskId }, + }) + .then((resp) => { + const authorsData = resp.data + + // Verify we received the correct type of response + if (!authorsData.authors || !Array.isArray(authorsData.authors)) { + console.error( + `[Authors] Invalid response format for task ${taskId}, missing authors array:`, + authorsData, + ) + throw new Error(`Invalid response format: missing authors array`) + } + return authorsData + }) + .catch((error) => { + console.error( + `[Authors] Failed to get recommendations for task ${taskId}:`, + error.response.data?.error || error.message, + ) + return { + authors: DEFAULT_AUTHORS, + } + }) } // Save author recommendations to database @@ -177,9 +152,7 @@ async function saveAuthorRecommendations( }) if (!existingFile) { - console.error( - `[Authors] Cannot save recommendations: File ${fileId} not found in database`, - ) + console.error(`[Authors] Cannot save recommendations: File ${fileId} not found in database`) return } @@ -235,7 +208,10 @@ async function saveAuthorRecommendations( } // Hook for polling author task status and handling completion -export function useQueryAuthorStatus(taskId: string | null | undefined, fileId: string | null | undefined) { +export function useQueryAuthorStatus( + taskId: string | null | undefined, + fileId: string | null | undefined, +) { const client = usePGlite() const db = drizzle({ client, schema }) @@ -257,10 +233,7 @@ export function useQueryAuthorStatus(taskId: string | null | undefined, fileId: // Verify this is a valid response before saving if (!result.authors || !Array.isArray(result.authors)) { - console.error( - `[Authors] Received invalid response for task ${taskId}:`, - result, - ) + console.error(`[Authors] Received invalid response for task ${taskId}:`, result) throw new Error("Invalid response format: missing authors array") } @@ -506,6 +479,6 @@ export function useProcessAuthors() { console.error(`[Authors] Failed to process authors for ${fileId}:`, error) return { submitted: false, error } } - } + }, }) } diff --git a/packages/morph/services/context-aware-notes.ts b/packages/morph/services/context-aware-notes.ts index 199ffb7..72c7471 100644 --- a/packages/morph/services/context-aware-notes.ts +++ b/packages/morph/services/context-aware-notes.ts @@ -1,4 +1,3 @@ -import { API_ENDPOINT } from "@/services/constants" import { and, cosineDistance, eq, inArray, sql } from "drizzle-orm" import { PgliteDatabase, drizzle } from "drizzle-orm/pglite" import { useCallback } from "react" @@ -111,36 +110,6 @@ export async function findSimilarNotesForFile( } } -/** - * Get embeddings from OpenAI-compatible endpoint - * @param text Text to get embeddings for - * @returns Embedding vector - */ -export async function getEmbedding(text: string): Promise { - try { - const response = await fetch(`${API_ENDPOINT}/v1/embeddings`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - input: text, - model: "text-embedding-3-small", - }), - }) - - if (!response.ok) { - throw new Error(`Failed to get embedding: ${response.statusText}`) - } - - const data = await response.json() - return data.data[0].embedding - } catch (error) { - console.error("Error getting embedding:", error) - throw error - } -} - // Add a retry wrapper function and improve the useContextAwareNotes hook // Add a function to retry operations async function withRetry(operation: () => Promise, retries = 3, delay = 500): Promise { diff --git a/packages/morph/services/essays.ts b/packages/morph/services/essays.ts index dd2c5d7..7693d9b 100644 --- a/packages/morph/services/essays.ts +++ b/packages/morph/services/essays.ts @@ -1,6 +1,8 @@ +import { safeDate } from "@/lib" import { API_ENDPOINT, ESAAY_POLLING_INTERVAL } from "@/services/constants" import { TaskStatusResponse } from "@/services/constants" import { useMutation, useQuery } from "@tanstack/react-query" +import axios from "axios" import { and, eq, isNull, not } from "drizzle-orm" import { PgliteDatabase, drizzle } from "drizzle-orm/pglite" @@ -85,101 +87,69 @@ async function submitFileEmbeddingTask( fileId: string, content: string, ): Promise { - const req: EssayEmbeddingRequest = { - vault_id: vaultId, - file_id: fileId, - content, - } - - const response = await fetch(`${API_ENDPOINT}/essays/submit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(req), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[EssayEmbedding] Failed to submit file ${fileId}:`, - error.error || response.statusText, + return axios + .post( + `${API_ENDPOINT}/essays/submit`, + { vault_id: vaultId, file_id: fileId, content }, ) - throw new Error(`Failed to submit file: ${error.error || response.statusText}`) - } - - const responseData = await response.json() - return responseData + .then((resp) => resp.data) + .catch((err) => { + console.error( + `[EssayEmbedding] Failed to submit file ${fileId}:`, + err.response?.data?.error || err.message, + ) + throw new Error(`Failed to submit file: ${err.response?.data?.error || err.message}`) + }) } // Check the status of an embedding task async function checkFileEmbeddingTask(taskId: string): Promise { - try { - const response = await fetch(`${API_ENDPOINT}/essays/status?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) + return axios + .get(`${API_ENDPOINT}/essays/status`, { + params: { task_id: taskId }, + }) + .then((resp) => resp.data) + .catch((error) => { console.error( `[EssayEmbedding] Status check failed for task ${taskId}:`, - error.error || response.statusText, + error.response?.data?.error || error.message, ) - throw new Error(`Failed to check status: ${error.error || response.statusText}`) - } - - const statusData = await response.json() - return statusData - } catch (error) { - console.error(`[EssayEmbedding] Error checking task status for ${taskId}:`, error) - // Return a failure status for any errors - return { - task_id: taskId, - status: "failure" as const, - created_at: new Date().toISOString(), - executed_at: new Date().toISOString(), - } - } + return { + task_id: taskId, + status: "failure" as const, + created_at: new Date().toISOString(), + executed_at: new Date().toISOString(), + } + }) } // Get embedding results for a completed task async function getFileEmbeddingTask(taskId: string): Promise { - const response = await fetch(`${API_ENDPOINT}/essays/get?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[EssayEmbedding] Failed to get embedding for task ${taskId}:`, - error.error || response.statusText, - ) - throw new Error(`Failed to get embedding: ${error.error || response.statusText}`) - } - - const embedData = await response.json() - - // Verify we received the correct type of response - if (!embedData.nodes || !Array.isArray(embedData.nodes)) { - console.error( - `[EssayEmbedding] Invalid response format for task ${taskId}, missing nodes array:`, - embedData, - ) - throw new Error(`Invalid response format: missing nodes array`) - } - - return embedData -} - -// Add a helper function to safely create dates -function safeDate(dateStr: string | null | undefined): Date | null { - if (!dateStr) return null + return axios + .get(`${API_ENDPOINT}/essays/get`, { + params: { task_id: taskId }, + }) + .then((resp) => { + const embedData = resp.data + + // Verify we received the correct type of response + if (!embedData.nodes || !Array.isArray(embedData.nodes)) { + console.error( + `[EssayEmbedding] Invalid response format for task ${taskId}, missing nodes array:`, + embedData, + ) + throw new Error(`Invalid response format: missing nodes array`) + } - try { - const date = new Date(dateStr) - // Check if date is valid - if (isNaN(date.getTime())) { - return null - } - return date - } catch (error) { - console.error(`Failed to parse date string: ${dateStr}`, error) - return null - } + return embedData + }) + .catch((error) => { + console.error( + `[EssayEmbedding] Failed to get embedding for task ${taskId}:`, + error.response?.data?.error || error.message, + ) + throw new Error(`Failed to get embedding: ${error.response?.data?.error || error.message}`) + }) } // Save embeddings to database diff --git a/packages/morph/services/notes.ts b/packages/morph/services/notes.ts index 2772e0c..5dfbfa0 100644 --- a/packages/morph/services/notes.ts +++ b/packages/morph/services/notes.ts @@ -1,6 +1,8 @@ +import { safeDate } from "@/lib" import { API_ENDPOINT, POLLING_INTERVAL } from "@/services/constants" import { TaskStatusResponse } from "@/services/constants" import { useMutation, useQuery } from "@tanstack/react-query" +import axios from "axios" import { and, eq, isNull, not } from "drizzle-orm" import { PgliteDatabase, drizzle } from "drizzle-orm/pglite" @@ -42,84 +44,71 @@ export async function checkNoteHasEmbedding( // Submit a note for embedding async function submitNoteEmbeddingTask(note: Note): Promise { - const req: NoteEmbeddingRequest = { - vault_id: note.vaultId, - file_id: note.fileId, - note_id: note.id, - content: note.content, - } - const response = await fetch(`${API_ENDPOINT}/notes/submit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(req), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[Embedding] Failed to submit note ${note.id}:`, - error.error || response.statusText, - ) - throw new Error(`Failed to submit note: ${error.error || response.statusText}`) - } - - const responseData = await response.json() - return responseData + return axios + .post(`${API_ENDPOINT}/notes/submit`, { + vault_id: note.vaultId, + file_id: note.fileId, + note_id: note.id, + content: note.content, + }) + .then((resp) => resp.data) + .catch((err) => { + console.error( + `[Embedding] Failed to submit note ${note.id}:`, + err.response?.data?.error || err.message, + ) + throw new Error(`Failed to submit note: ${err.response?.data?.error || err.message}`) + }) } // Check the status of an embedding task async function checkNoteEmbeddingTask(taskId: string): Promise { - try { - const response = await fetch(`${API_ENDPOINT}/notes/status?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) + return axios + .get(`${API_ENDPOINT}/notes/status`, { + params: { task_id: taskId }, + }) + .then((resp) => resp.data) + .catch((error) => { console.error( `[Embedding] Status check failed for task ${taskId}:`, - error.error || response.statusText, + error.response?.data?.error || error.message, ) - throw new Error(`Failed to check status: ${error.error || response.statusText}`) - } - - const statusData = await response.json() - return statusData - } catch (error) { - console.error(`[Embedding] Error checking task status for ${taskId}:`, error) - // Return a failure status for any errors - return { - task_id: taskId, - status: "failure" as const, - created_at: new Date().toISOString(), - executed_at: new Date().toISOString(), - } - } + return { + task_id: taskId, + status: "failure" as const, + created_at: new Date().toISOString(), + executed_at: new Date().toISOString(), + } + }) } // Get embedding results for a completed task async function getNoteEmbeddingTask(taskId: string): Promise { - const response = await fetch(`${API_ENDPOINT}/notes/get?task_id=${taskId}`) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - console.error( - `[Embedding] Failed to get embedding for task ${taskId}:`, - error.error || response.statusText, - ) - throw new Error(`Failed to get embedding: ${error.error || response.statusText}`) - } - - const embedData = await response.json() - - // Validate the response is a note embedding response - if (!embedData.note_id || !embedData.embedding || !Array.isArray(embedData.embedding)) { - console.error( - `[Embedding] Invalid note embedding response format for task ${taskId}:`, - embedData, - ) - throw new Error(`Invalid response format: missing note_id or embedding array`) - } + return axios + .get(`${API_ENDPOINT}/notes/get`, { + params: { task_id: taskId }, + }) + .then((resp) => { + const embedData = resp.data + + // Validate the response is a note embedding response + if (!embedData.note_id || !embedData.embedding || !Array.isArray(embedData.embedding)) { + console.error( + `[Embedding] Invalid note embedding response format for task ${taskId}:`, + embedData, + ) + throw new Error(`Invalid response format: missing note_id or embedding array`) + } - return embedData + return embedData + }) + .catch((error) => { + console.error( + `[Embedding] Failed to get embedding for task ${taskId}:`, + error.response?.data?.error || error.message, + ) + throw new Error(`Failed to get embedding: ${error.response?.data?.error || error.message}`) + }) } // Save embedding to database @@ -206,23 +195,6 @@ async function saveNoteEmbedding( } } -// Add a helper function to safely create dates -function safeDate(dateStr: string | null | undefined): Date | null { - if (!dateStr) return null - - try { - const date = new Date(dateStr) - // Check if date is valid - if (isNaN(date.getTime())) { - return null - } - return date - } catch (error) { - console.error(`Failed to parse date string: ${dateStr}`, error) - return null - } -} - // Hook for polling embedding status and handling completion export function useQueryNoteEmbeddingStatus(taskId: string | null | undefined) { const client = usePGlite() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8771016..bdb266c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: '@uiw/react-codemirror': specifier: ^4.23.10 version: 4.23.10(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.5)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + axios: + specifier: ^1.8.4 + version: 1.8.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3770,6 +3773,9 @@ packages: async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3778,6 +3784,9 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3939,6 +3948,10 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4175,6 +4188,10 @@ packages: delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4663,6 +4680,15 @@ packages: flexsearch@0.8.149: resolution: {integrity: sha512-VoqwZoEJ9sAsFfkagE7e11B7ZG7C9iz00bPcTpgA8sZnsIeioenpM4DUhlTKRQvpepqoKPPW7kCur7j28pxrpw==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4671,6 +4697,10 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -5882,6 +5912,9 @@ packages: property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pseudocode@2.4.1: resolution: {integrity: sha512-PUslrULyVbqk72+L4D4dvDwD8KPOGazSM9oQhB9a05ab+BPsvwFy6rm/pX6vSFp0IiCa1Isi/lmNE2oHqmr+rw==} @@ -9703,12 +9736,22 @@ snapshots: dependencies: tslib: 2.8.0 + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.10.3: {} + axios@1.8.4: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -9876,6 +9919,10 @@ snapshots: colorjs.io@0.5.2: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@7.2.0: {} @@ -10126,6 +10173,8 @@ snapshots: dependencies: robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-gpu@5.0.70: @@ -10772,6 +10821,8 @@ snapshots: flexsearch@0.8.149: {} + follow-redirects@1.15.9: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10781,6 +10832,13 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.18 + format@0.2.2: {} framer-motion@12.6.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -12272,6 +12330,8 @@ snapshots: property-information@7.0.0: {} + proxy-from-env@1.1.0: {} + pseudocode@2.4.1: dependencies: katex: 0.16.21 From e74a5cfb6e8129f62deab56e77afdd5b15d050e1 Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Fri, 4 Apr 2025 20:49:44 -0400 Subject: [PATCH 02/12] chore: --wip-- Signed-off-by: Aaron Pham --- packages/morph/components/editor.tsx | 639 ++++++++++-------- .../morph/components/embedding-status.tsx | 14 +- packages/morph/components/note-panel.tsx | 325 +++++---- packages/morph/components/reasoning-panel.tsx | 180 +++-- packages/morph/context/steering.tsx | 18 +- 5 files changed, 600 insertions(+), 576 deletions(-) diff --git a/packages/morph/components/editor.tsx b/packages/morph/components/editor.tsx index 2b9239a..1acd741 100644 --- a/packages/morph/components/editor.tsx +++ b/packages/morph/components/editor.tsx @@ -11,7 +11,6 @@ import { checkAgentAvailability, checkAgentHealth, } from "@/services/agents" -import { submitContentForAuthors } from "@/services/authors" import { checkFileHasEmbeddings, useProcessPendingEssayEmbeddings } from "@/services/essays" import { checkNoteHasEmbedding, @@ -25,7 +24,7 @@ import { Compartment, EditorState } from "@codemirror/state" import { keymap } from "@codemirror/view" import { EditorView } from "@codemirror/view" import { createId } from "@paralleldrive/cuid2" -import { CopyIcon, Cross2Icon, GlobeIcon } from "@radix-ui/react-icons" +import { CopyIcon, Cross2Icon } from "@radix-ui/react-icons" import { Vim, vim } from "@replit/codemirror-vim" import { hyperLink } from "@uiw/codemirror-extensions-hyper-link" import CodeMirror from "@uiw/react-codemirror" @@ -58,7 +57,6 @@ import { VaultButton } from "@/components/ui/button" import { DotIcon } from "@/components/ui/icons" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" -import { useAuthorTasks } from "@/context/authors" import { usePGlite } from "@/context/db" import { useEmbeddingTasks, useEssayEmbeddingTasks } from "@/context/embedding" import { useRestoredFile } from "@/context/file-restoration" @@ -119,8 +117,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const [currentFileHandle, setCurrentFileHandle] = useState(null) const [scanAnimationComplete, setScanAnimationComplete] = useState(false) const [showEphemeralBanner, setShowEphemeralBanner] = useState(false) - const [isOnline, setIsOnline] = useState(true) - const [isClient, setIsClient] = useState(false) const codeMirrorViewRef = useRef(null) const readingModeRef = useRef(null) const [showNotes, setShowNotes] = useState(false) @@ -149,6 +145,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const [currentFileId, setCurrentFileId] = useState(null) // Add a state for visible context note IDs (around line 150, with other state declarations) const [visibleContextNoteIds, setVisibleContextNoteIds] = useState([]) + // Add state for author understanding indicator + const [isAuthorProcessing, setIsAuthorProcessing] = useState(false) // Add a ref to track loaded files to prevent duplicate note loading const loadedFiles = useRef>(new Set()) @@ -167,17 +165,11 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Get the task actions for adding tasks const { addTask, pendingTaskIds } = useEmbeddingTasks() const { addTask: addEssayTask, pendingTaskIds: essayPendingTaskIds } = useEssayEmbeddingTasks() - const { addTask: addAuthorTask } = useAuthorTasks() const toggleStackExpand = useCallback(() => { setIsStackExpanded((prev) => !prev) }, []) - // Effect to set isClient to true after component mounts - useEffect(() => { - setIsClient(true) - }, []) - // Add a ref to track initial settings load const initialSettingsLoadComplete = useRef(false) @@ -320,12 +312,19 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { lastModified: new Date(note.accessedAt), })) - // Update the state with fetched notes in a non-blocking way + // Reset note states before updating to avoid merging with previous file's notes + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + + // Update the state with fetched notes atomically to prevent flicker setNotes(uiReadyRegularNotes) setDroppedNotes(uiReadyDroppedNotes) // In parallel, fetch and process reasoning if needed - const reasoningIds = [...new Set(fileNotes.map((note) => note.reasoningId))] + const reasoningIds = [ + ...new Set(fileNotes.map((note) => note.reasoningId).filter(Boolean)), + ] if (reasoningIds.length > 0) { const reasonings = await db .select() @@ -346,18 +345,43 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { numSuggestions: r.steering?.numSuggestions, })) - // Update reasoning history state in a non-blocking way - React.startTransition(() => { - setReasoningHistory(reasoningHistory) - }) + // Update reasoning history state atomically + setReasoningHistory(reasoningHistory) + } else { + // Reset reasoning history if no reasonings found + setReasoningHistory([]) } + } else { + // Reset reasoning history if no reasoning IDs + setReasoningHistory([]) } + } else { + // No notes found for this file, reset all note states + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) } + + // Reset embedding processing flags for the new file + embeddingProcessedRef.current = false + essayEmbeddingProcessedRef.current = false } catch (error) { console.error("Error fetching notes for file:", error) + // Reset note states on error + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) + throw error } } catch (dbError) { console.error("Error with database operations:", dbError) + throw dbError } }, [db, vault], @@ -388,7 +412,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Effect to use preloaded file from context if available useEffect(() => { - if (!isClient || !restoredFile || fileRestorationAttempted.current) return + if (!restoredFile || fileRestorationAttempted.current) return + + // Mark file as restored so we don't try again + fileRestorationAttempted.current = true // Update local state even before CodeMirror is ready setCurrentFile(restoredFile.fileName) @@ -399,85 +426,99 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Update preview immediately with the restored content updatePreview(restoredFile.content) - // Reset all notes states in one batch to ensure clean state for the restored file - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - - // Reset the processing flags to ensure new embeddings are processed - embeddingProcessedRef.current = false - essayEmbeddingProcessedRef.current = false + // Create flags to track processing status + let embeddingProcessingStarted = false // If CodeMirror is ready, also update it if (codeMirrorViewRef.current) { loadFileContent(restoredFile.fileName, restoredFile.fileHandle, restoredFile.content) - // Load file metadata once - loadFileMetadataOnce(restoredFile.fileName) - - // Process essay embeddings for restored file (only if online) - if (isOnline && vault) { - // Use a small timeout to let the file load completely first - setTimeout(async () => { + // Load file metadata - we'll reset note states only after we've loaded the metadata + if (vault && restoredFile.fileName !== "Untitled") { + // Load file metadata + ;(async () => { try { - // Find the file in the database to get its ID - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, restoredFile.fileName), eq(files.vaultId, vault.id)), - }) + await loadFileMetadata(restoredFile.fileName) - if (dbFile) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) - - if (!hasEmbeddings) { - // Process essay embeddings asynchronously using the md content function - const content = md(restoredFile.content).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: content, - currentVaultId: vault.id, - currentFileId: dbFile.id, + // Process essay embeddings for restored file (only if online) + // Use a small timeout to let the file load completely first + setTimeout(async () => { + try { + // Skip if processing has already started or if file is already processed + if (embeddingProcessingStarted || essayEmbeddingProcessedRef.current) { + console.log( + "[Debug] Skipping duplicate essay embedding processing for restored file", + ) + return + } + + // Mark as processing immediately + embeddingProcessingStarted = true + essayEmbeddingProcessedRef.current = true + + // Find the file in the database to get its ID + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => + and(eq(files.name, restoredFile.fileName), eq(files.vaultId, vault.id)), }) - } - // Process any existing notes for this file - processEmbeddings.mutate({ addTask }) - } + if (dbFile) { + // Check if this file already has embeddings + const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) + + if (!hasEmbeddings) { + console.log("[Debug] Processing embeddings for restored file") + // Process essay embeddings asynchronously using the md content function + const content = md(restoredFile.content).content + processEssayEmbeddings.mutate({ + addTask: addEssayTask, + currentContent: content, + currentVaultId: vault.id, + currentFileId: dbFile.id, + }) + } else { + console.log("[Debug] Restored file already has embeddings") + } - // Also process author recommendations if online - if (!authorsProcessedRef.current && dbFile) { - try { - // Get the file content - const fileContent = md(restoredFile.content).content - const taskId = await submitContentForAuthors(db, dbFile.id, fileContent) - if (taskId) { - addAuthorTask(taskId, dbFile.id) - authorsProcessedRef.current = true + // Process any existing notes for this file (only once) + if (!embeddingProcessedRef.current) { + embeddingProcessedRef.current = true + processEmbeddings.mutate({ addTask }) + } } } catch (error) { - console.error("[Editor] Error processing authors:", error) + console.error("Error processing file data:", error) + // Reset flags on error to allow retrying + essayEmbeddingProcessedRef.current = false + embeddingProcessedRef.current = false } - } + }, 1000) // 1 second delay to ensure DB operations are ready } catch (error) { - console.error("Error processing file data:", error) + console.error("Error loading file metadata:", error) + // If metadata loading fails, reset the notes state to avoid stale data + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) } - }, 1000) // 1 second delay to ensure DB operations are ready + })() + } else { + // For new/unsaved files, we do want to reset the notes state + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) } } - - // Mark file as restored so we don't try again - fileRestorationAttempted.current = true }, [ - isClient, setRestoredFile, restoredFile, loadFileContent, - loadFileMetadataOnce, - isOnline, + loadFileMetadata, vault, db, processEmbeddings, @@ -493,42 +534,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { loadedFiles.current.clear() }, [vaultId]) - // Effect to track online/offline status - useEffect(() => { - // Only run this effect on the client after it has mounted - if (!isClient) return - - // Set initial state based on navigator now that we are on the client - setIsOnline(navigator.onLine) - - const handleOnline = () => setIsOnline(true) - const handleOffline = () => setIsOnline(false) - - window.addEventListener("online", handleOnline) - window.addEventListener("offline", handleOffline) - - // Initial check in case the event listeners haven't fired yet - setIsOnline(navigator.onLine) - - return () => { - window.removeEventListener("online", handleOnline) - window.removeEventListener("offline", handleOffline) - } - // Rerun specifically when isClient becomes true to set initial state - }, [isClient]) - const toggleNotes = useCallback(() => { - // Check isClient here for safety, though interaction implies client-side - if (isClient && !isOnline) { - toast({ - title: "Notes panel requires an internet connection.", - description: "Please check your connection and try again.", - duration: 5000, - variant: "destructive", - }) - return // Prevent opening the panel - } - // Instead of using setState callback, directly use a variable for performance const shouldShowNotes = !showNotes @@ -669,9 +675,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Update state after all synchronous operations setShowNotes(shouldShowNotes) }, [ - isOnline, - isClient, - toast, currentGenerationNotes, currentlyGeneratingDateKey, currentFile, @@ -1223,7 +1226,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { reasoningElapsedTime, // Add steering parameters if they exist authors: steeringOptions?.authors, - tonality: steeringOptions?.tonality && steeringOptions?.tonality, + tonality: steeringOptions?.tonality, temperature: steeringOptions?.temperature, numSuggestions: numSuggestions, } @@ -1304,7 +1307,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } // Save the current file info to localStorage for this vault - if (isClient && vaultId) { + if (vaultId) { try { const fileName = targetHandle.name localStorage.setItem( @@ -1323,16 +1326,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setHasUnsavedChanges(false) } catch {} - }, [ - currentFileHandle, - markdownContent, - currentFile, - vault, - refreshVault, - vaultId, - isClient, - storeHandle, - ]) + }, [currentFileHandle, markdownContent, currentFile, vault, refreshVault, vaultId, storeHandle]) const memoizedExtensions = useMemo(() => { const tabSize = new Compartment() @@ -1369,13 +1363,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } }, [markdownContent, updatePreview, restoredFile]) - // Add an effect to update preview specifically when a file is restored - useEffect(() => { - if (restoredFile?.content) { - updatePreview(restoredFile.content) - } - }, [restoredFile, updatePreview]) - const onNewFile = useCallback(() => { setCurrentFileHandle(null) setCurrentFile("Untitled") @@ -1388,14 +1375,14 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setHasUnsavedChanges(false) // Reset unsaved changes // Clear the last file info from localStorage - if (isClient && vaultId) { + if (vaultId) { try { localStorage.removeItem(`morph:last-file:${vaultId}`) } catch (storageError) { console.error("Failed to clear file info from localStorage:", storageError) } } - }, [isClient, vaultId]) + }, [vaultId]) // Create a function to toggle settings that we can pass to Rails const toggleSettings = useCallback(() => { @@ -1427,23 +1414,19 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { [handleSave, toggleNotes, settings, toggleSettings], ) - // Separate effect specifically for global keyboard shortcuts - useEffect(() => { - // Add event listener for keyboard shortcuts - const handler = (e: KeyboardEvent) => handleKeyDown(e) - window.addEventListener("keydown", handler, { capture: true }) // Use capture to get event before other handlers - - return () => window.removeEventListener("keydown", handler, { capture: true }) - }, [handleKeyDown]) - - // Effect for vim mode and vim-specific keybindings + // Separate effect specifically for global keyboard shortcuts, and vim mode useEffect(() => { Vim.defineEx("w", "w", handleSave) Vim.defineEx("wa", "w", handleSave) Vim.map(";", ":", "normal") Vim.map("jj", "", "insert") Vim.map("jk", "", "insert") - }, [handleSave]) + + const handler = (e: KeyboardEvent) => handleKeyDown(e) + window.addEventListener("keydown", handler, { capture: true }) // Use capture to get event before other handlers + + return () => window.removeEventListener("keydown", handler, { capture: true }) + }, [handleKeyDown, handleSave]) const generateNewSuggestions = useCallback( async (steeringSettings: SteeringSettings) => { @@ -1484,13 +1467,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setCurrentlyGeneratingDateKey(dateKey) try { - // Check if we're online before attempting to generate suggestions - if (!isOnline) { - throw new Error( - "Cannot generate suggestions while offline. Please check your internet connection.", - ) - } - // First, find or create the file in the database to ensure proper ID reference let dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => @@ -1681,7 +1657,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { db, currentGenerationNotes, addTask, - isOnline, ], ) @@ -1811,12 +1786,24 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { [droppedNotes, db, currentFile, vault, setNotes], ) - // Update handleFileSelect to save the handle ID + // Update handleFileSelect to use processAuthors const handleFileSelect = useCallback( async (node: FileSystemTreeNode) => { if (!vault || node.kind !== "file" || !codeMirrorViewRef.current || !node.handle) return try { + // Add a flag to track if processing has already started for this file + const fileKey = `${vaultId}:${node.name}` + if (loadedFiles.current.has(fileKey)) { + console.log( + `[Debug] File ${node.name} is already being processed, skipping duplicate processing`, + ) + return + } + + // Mark file as being processed immediately to prevent race conditions + loadedFiles.current.add(fileKey) + // Only do file I/O once const file = await node.handle!.getFile() const content = await file.text() @@ -1846,7 +1833,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } // Save the current file info to localStorage for this vault - if (isClient && vaultId) { + if (vaultId) { try { localStorage.setItem( `morph:last-file:${vaultId}`, @@ -1866,68 +1853,80 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const success = loadFileContent(fileName, node.handle as FileSystemFileHandle, content) if (success) { - // Reset all notes states in one batch - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - - // Reset the processing flags to ensure new embeddings are processed - embeddingProcessedRef.current = false - essayEmbeddingProcessedRef.current = false + try { + // Load file metadata - this will handle resetting and updating notes and reasonings + await loadFileMetadata(fileName) - // Load file metadata once - await loadFileMetadataOnce(fileName) + // Process essay embeddings for file that's not "Untitled" + if (fileName !== "Untitled") { + try { + // First check if we've already started processing + if (essayEmbeddingProcessedRef.current) { + console.log( + `[Debug] Essay embeddings for ${fileName} already being processed, skipping`, + ) + return + } - // Process essay embeddings if we're online - if (isClient && isOnline) { - try { - // Find the file in the database to get its ID - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, fileName), eq(files.vaultId, vault.id)), - }) + // Mark as processing immediately to prevent race conditions + essayEmbeddingProcessedRef.current = true - if (dbFile) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) - - if (!hasEmbeddings) { - // Process essay embeddings asynchronously - const cleaned = md(content).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: cleaned, - currentVaultId: vault.id, - currentFileId: dbFile.id, - }) - } + // Find the file in the database to get its ID + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => + and(eq(files.name, fileName), eq(files.vaultId, vault.id)), + }) - // Process any existing notes for this file - processEmbeddings.mutate({ addTask }) - } + if (dbFile) { + // Check if this file already has embeddings + const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) + + if (!hasEmbeddings) { + // Process essay embeddings asynchronously + console.log(`[Debug] Processing essay embeddings for ${fileName}`) + const cleaned = md(content).content + processEssayEmbeddings.mutate({ + addTask: addEssayTask, + currentContent: cleaned, + currentVaultId: vault.id, + currentFileId: dbFile.id, + }) + } else { + console.log( + `[Debug] File ${fileName} already has embeddings, skipping processing`, + ) + } - // Also process author recommendations - if (!authorsProcessedRef.current && dbFile) { - try { - // Get the file content - const fileContent = content // Use the content variable directly - const taskId = await submitContentForAuthors(db, dbFile.id, fileContent) - if (taskId) { - addAuthorTask(taskId, dbFile.id) - authorsProcessedRef.current = true + // Process any existing notes for this file only once + if (!embeddingProcessedRef.current) { + embeddingProcessedRef.current = true + processEmbeddings.mutate({ addTask }) } - } catch (error) { - console.error("[Editor] Error processing authors:", error) } + } catch (error) { + console.error("Error processing file data:", error) + // Reset flags on error to allow retrying + essayEmbeddingProcessedRef.current = false + embeddingProcessedRef.current = false } - } catch (error) { - console.error("Error processing file data:", error) } + } catch (loadError) { + console.error("Error loading file metadata:", loadError) + // Reset the note states if metadata loading fails + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) + + // Remove from loaded files to allow retrying + loadedFiles.current.delete(fileKey) } } else { + // Remove from loaded files if loading fails + loadedFiles.current.delete(fileKey) + toast({ title: "Error Loading File", description: `Could not load file ${node.name}. Please try again.`, @@ -1936,6 +1935,11 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } } catch (error) { console.error("Error handling file selection:", error) + + // Make sure to remove the file from loaded files on error + const fileKey = `${vaultId}:${node.name}` + loadedFiles.current.delete(fileKey) + toast({ title: "Error Loading File", description: `Could not load file ${node.name}. Please try again.`, @@ -1946,10 +1950,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { [ vault, vaultId, - isClient, - isOnline, loadFileContent, - loadFileMetadataOnce, + loadFileMetadata, toast, storeHandle, db, @@ -1957,21 +1959,15 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { addTask, processEmbeddings, processEssayEmbeddings, - addAuthorTask, ], ) // Add this ref to track if we've processed embeddings for the current file const embeddingProcessedRef = useRef(false) - // Reset the embedding processed flag when the file changes - useEffect(() => { - embeddingProcessedRef.current = false - }, [currentFile]) - // Add an effect to process all notes in the editor for embeddings useEffect(() => { - if (!isClient || !isOnline || notes.length === 0) return + if (notes.length === 0) return // Check and process notes for embeddings after a short delay to avoid performance issues const timeoutId = setTimeout(async () => { @@ -2027,7 +2023,54 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { }, 2000) // 2 second delay return () => clearTimeout(timeoutId) - }, [isClient, isOnline, currentFile, addTask, db, notes]) // Only depend on stable values, not notes array itself + }, [currentFile, db]) // Only depend on stable values to avoid frequent re-renders + + // Add an effect to process file embeddings on mount and file change + useEffect(() => { + if (currentFile === "Untitled") return + + // Process only if we haven't already for this file + if (essayEmbeddingProcessedRef.current) return + + // Use a timeout to avoid processing immediately on mount + const timeoutId = setTimeout(async () => { + try { + // First check if we've already processed this file (double-check) + if (essayEmbeddingProcessedRef.current) return + + // Mark as processed right away to prevent race conditions + essayEmbeddingProcessedRef.current = true + + // Find the file in the database + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => + and(eq(files.name, currentFile), eq(files.vaultId, vault?.id || "")), + }) + + if (dbFile && markdownContent) { + // Check if this file already has embeddings + const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) + + if (!hasEmbeddings) { + // Process essay embeddings asynchronously + const content = md(markdownContent).content + processEssayEmbeddings.mutate({ + addTask: addEssayTask, + currentContent: content, + currentVaultId: vault?.id || "", + currentFileId: dbFile.id, + }) + } + } + } catch (error) { + console.error("[EssayEmbedding] Failed to process file embeddings:", error) + // Reset the flag on error so we can try again + essayEmbeddingProcessedRef.current = false + } + }, 3000) // 3 second delay + + return () => clearTimeout(timeoutId) + }, [currentFile, db, markdownContent, processEssayEmbeddings, addEssayTask]) // Use the embedding status hook // This hooks has some side-effects, and must be called at the very last. @@ -2085,7 +2128,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Add an effect to process file embeddings on mount and file change useEffect(() => { - if (!isClient || !isOnline || currentFile === "Untitled") return + if (currentFile === "Untitled") return // Process only if we haven't already for this file if (essayEmbeddingProcessedRef.current) return @@ -2093,6 +2136,12 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Use a timeout to avoid processing immediately on mount const timeoutId = setTimeout(async () => { try { + // First check if we've already processed this file (double-check) + if (essayEmbeddingProcessedRef.current) return + + // Mark as processed right away to prevent race conditions + essayEmbeddingProcessedRef.current = true + // Find the file in the database const dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => @@ -2113,26 +2162,16 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { currentFileId: dbFile.id, }) } - - // Mark as processed - essayEmbeddingProcessedRef.current = true } } catch (error) { console.error("[EssayEmbedding] Failed to process file embeddings:", error) + // Reset the flag on error so we can try again + essayEmbeddingProcessedRef.current = false } }, 3000) // 3 second delay return () => clearTimeout(timeoutId) - }, [ - isClient, - isOnline, - currentFile, - vault, - db, - markdownContent, - processEssayEmbeddings, - addEssayTask, - ]) + }, [currentFile, db, markdownContent, processEssayEmbeddings, addEssayTask]) // Add an effect to update CodeMirror when vim mode changes useEffect(() => { @@ -2155,9 +2194,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Effect to check for vim mode change notification in localStorage useEffect(() => { - // Only run on the client side - if (!isClient) return - // Check if there's a vim mode change notification in localStorage const vimModeChanged = localStorage.getItem("morph:vim-mode-changed") === "true" @@ -2175,15 +2211,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { return () => clearTimeout(timer) } - }, [isClient]) // Add isClient as a dependency - - // Add a ref to track if we've processed authors for the current file - const authorsProcessedRef = useRef(false) - - // Reset the author processed flag when the file changes - useEffect(() => { - authorsProcessedRef.current = false - }, [currentFile]) + }, []) // Add a ref to track the current file name to avoid unnecessary database queries // Add near other ref declarations (around line 150) @@ -2278,6 +2306,50 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { )} + {/* Author processing indicator */} + + {isAuthorProcessing && ( + + + + + + + Building document context for better suggestions... + + + + )} + + {/* Add vim mode change notification banner */} {showVimModeChangeNotification && ( @@ -2320,21 +2392,13 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) {
@@ -2342,7 +2406,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) {
{hasUnsavedChanges && } - {isClient && !isOnline && } {embeddingStatus && }
{vault && currentFileId && memoizedDroppedNotes.length > 0 && !isNotesLoading && ( @@ -2395,42 +2458,34 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { - - {showNotes && ( - - - - )} - + {showNotes && ( +
+ +
+ )} {eyeIconState === "open" ? ( - + ) : ( - + )} - - Indexing... + + indexing ) @@ -93,7 +93,7 @@ export function EmbeddingStatus({ status, className }: EmbeddingStatusProps) { )} title="Indexing complete" > - + ) } @@ -101,7 +101,7 @@ export function EmbeddingStatus({ status, className }: EmbeddingStatusProps) { if (status === "failure" || status === "cancelled") { return (
- +
) } diff --git a/packages/morph/components/note-panel.tsx b/packages/morph/components/note-panel.tsx index 9ad3520..5b1b34a 100644 --- a/packages/morph/components/note-panel.tsx +++ b/packages/morph/components/note-panel.tsx @@ -161,58 +161,48 @@ const DriversBar = memo( return (
- - {isSteeringExpanded && ( - -
-

Interpreter

- -
- -
-
- -
+ {isSteeringExpanded && ( +
+
+

Interpreter

+ +
-
- -
+
+
+ +
-
- -
+
+ +
-
- -
+
+
- - )} - +
+ +
+
+
+ )}
{!isNotesLoading && notes.length === 0 ? ( -
-

- {droppedNotes.length !== 0 - ? "All notes are currently in the stack." - : "No notes found for this document"} -

-
+ ) : (
- {notesError ? ( -
- {notesError} -
- ) : ( -
- {/* Only show current generation section if there are active notes in it */} - {currentlyGeneratingDateKey && - (isNotesLoading || - (!scanAnimationComplete && reasoningComplete) || - (scanAnimationComplete && +
+ {notesError && ( +
+ {notesError} +
+ )} + {/* Only show current generation section if there are active notes in it */} + {!notesError && + currentlyGeneratingDateKey && + (isNotesLoading || + (!scanAnimationComplete && reasoningComplete) || + (scanAnimationComplete && + currentGenerationNotes.length > 0 && + !droppedNotes.some((d) => d.id === currentGenerationNotes[0]?.id))) && ( +
+
+ + + 0} + elapsedTime={currentReasoningElapsedTime} + /> +
+ + {/* Show streaming notes during generation phase */} + {reasoningComplete && + !scanAnimationComplete && + streamingNotes && + streamingNotes.length > 0 && ( +
+ {streamingNotes.map((note) => ( + + ))} +
+ )} + + {/* Show actual generated notes when complete */} + {!isNotesLoading && + scanAnimationComplete && currentGenerationNotes.length > 0 && - !droppedNotes.some((d) => d.id === currentGenerationNotes[0]?.id))) && ( -
-
- - - 0} - elapsedTime={currentReasoningElapsedTime} - /> -
- - {/* Show streaming notes during generation phase */} - {reasoningComplete && - !scanAnimationComplete && - streamingNotes && - streamingNotes.length > 0 && ( -
- {streamingNotes.map((note) => ( - + + {currentGenerationNotes.map((note) => ( + ))} -
- )} - - {/* Show actual generated notes when complete */} - {!isNotesLoading && - scanAnimationComplete && - currentGenerationNotes.length > 0 && - !notesError && ( - - - {currentGenerationNotes.map((note) => ( - - ))} - - - )} -
- )} -
- Math.abs(velocity) > 1000, - exit: (velocity) => Math.abs(velocity) < 100, - }} - customScrollParent={notesContainerRef.current!} - /> -
+ + + )} +
+ )} +
+ Math.abs(velocity) > 1000, + exit: (velocity) => Math.abs(velocity) < 100, + }} + customScrollParent={notesContainerRef.current!} + />
- )} +
)}
diff --git a/packages/morph/components/reasoning-panel.tsx b/packages/morph/components/reasoning-panel.tsx index 5261825..1af0c46 100644 --- a/packages/morph/components/reasoning-panel.tsx +++ b/packages/morph/components/reasoning-panel.tsx @@ -2,7 +2,6 @@ import { cn } from "@/lib/utils" import { ChevronRightIcon, TransformIcon } from "@radix-ui/react-icons" import { eq } from "drizzle-orm" import { drizzle } from "drizzle-orm/pglite" -import { AnimatePresence, motion } from "motion/react" import * as React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -10,48 +9,64 @@ import { usePGlite } from "@/context/db" import * as schema from "@/db/schema" -// Triple dot loading animation component -const LoadingDots = memo(function LoadingDots() { - const containerVariants = { - animate: { - transition: { - staggerChildren: 0.12, - }, - }, - } - const dotVariants = { - initial: { - scaleY: 1, - opacity: 0.5, - }, - animate: { - scaleY: [1, 2.2, 1], - opacity: [0.5, 1, 0.5], - transition: { - duration: 0.7, - repeat: Infinity, - repeatType: "loop" as const, - ease: "easeInOut", - }, - }, +// Memoized header component to prevent redundant renders +const ReasoningHeader = memo(function ReasoningHeader({ + isExpanded, + isHovering, + isStreaming, + isComplete, + elapsedTime, + toggleExpand, +}: { + isExpanded: boolean + isHovering: boolean + isStreaming: boolean + isComplete: boolean + elapsedTime: number + toggleExpand: () => void +}) { + // Format the duration nicely + const formattedDuration = (seconds: number) => { + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? "s" : ""}` + } else { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes} minute${minutes !== 1 ? "s" : ""} ${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}` + } } return ( - - {[0, 1, 2].map((dot) => ( - - ))} - + +
) }) @@ -112,20 +127,15 @@ export const ReasoningPanel = memo(function ReasoningPanel({ } }, [isStreaming]) - // Save reasoning to database when complete - batch updates to reduce database writes + // Save reasoning to database when complete, not during streaming useEffect(() => { - if (isStreaming && reasoning && currentFile && vaultId) { - // Debounce database updates to reduce writes - const debouncedUpdate = setTimeout(() => { - db.update(schema.reasonings) - .set({ content: reasoning }) - .where(eq(schema.reasonings.id, reasoningId)) - .execute() - }, 500) - - return () => clearTimeout(debouncedUpdate) + if (!isStreaming && isComplete && reasoning && currentFile && vaultId) { + db.update(schema.reasonings) + .set({ content: reasoning }) + .where(eq(schema.reasonings.id, reasoningId)) + .execute() } - }, [isStreaming, reasoning, currentFile, vaultId, reasoningId, db]) + }, [isStreaming, isComplete, reasoning, currentFile, vaultId, reasoningId, db]) // Auto-scroll to bottom when content changes and panel is expanded useEffect(() => { @@ -196,55 +206,27 @@ export const ReasoningPanel = memo(function ReasoningPanel({ onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > -
-
+ )}
) }) diff --git a/packages/morph/context/steering.tsx b/packages/morph/context/steering.tsx index daaf04d..b2fc64b 100644 --- a/packages/morph/context/steering.tsx +++ b/packages/morph/context/steering.tsx @@ -1,8 +1,9 @@ "use client" import * as React from "react" -import { createContext, useCallback, useContext, useEffect, useState } from "react" -import { useRecommendedAuthors } from "@/services/authors" +import { createContext, useCallback, useContext, useState } from "react" + +// import { useRecommendedAuthors } from "@/services/authors" // Default steering parameters export const DEFAULT_AUTHORS = [ @@ -59,19 +60,6 @@ export function SteeringProvider({ children }: SteeringProviderProps) { tonalityEnabled: false, }) - // Fetch recommended authors if fileId is available - const { data: recommendedAuthors } = useRecommendedAuthors(fileId) - - // Update authors when recommendations are available - useEffect(() => { - if (recommendedAuthors?.authors && recommendedAuthors.authors.length > 0) { - setSettings(prev => ({ - ...prev, - authors: [...recommendedAuthors.authors] - })) - } - }, [recommendedAuthors]) - const updateSettings = useCallback( (key: K, value: SteeringSettings[K]) => { setSettings((prev) => { From b40fe5199c7256ba4ffa0f9dcc91295a7820e98b Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Sat, 5 Apr 2025 18:45:05 -0400 Subject: [PATCH 03/12] --wip-- Signed-off-by: Aaron Pham --- packages/morph/app/globals.css | 4 +- packages/morph/app/template.tsx | 11 - packages/morph/app/vaults/[vault]/page.tsx | 63 +- packages/morph/app/vaults/page.tsx | 9 +- .../morph/components/author-processor.tsx | 3 +- packages/morph/components/dnd.tsx | 43 +- packages/morph/components/editor.tsx | 1951 +++++++++-------- .../components/essay-embedding-processor.tsx | 27 +- packages/morph/components/formatted-date.tsx | 2 +- .../components/landing/page-transition.tsx | 62 - packages/morph/components/markdown-inline.ts | 40 +- .../components/note-embedding-processor.tsx | 26 +- packages/morph/components/note-group.tsx | 21 +- packages/morph/components/note-panel.tsx | 380 ++-- .../components/parser/codemirror/index.ts | 3 +- .../components/parser/codemirror/search.tsx | 6 +- packages/morph/components/rails.tsx | 4 +- packages/morph/components/reasoning-panel.tsx | 23 +- packages/morph/context/authors.tsx | 9 +- packages/morph/context/file-restoration.tsx | 2 +- packages/morph/context/loading.tsx | 92 + packages/morph/context/providers.tsx | 53 +- packages/morph/db/index.ts | 54 +- packages/morph/hooks/use-fs-handles.ts | 1 + .../morph/hooks/use-persisted-settings.tsx | 140 +- packages/morph/package.json | 1 + packages/morph/services/authors.ts | 6 +- packages/morph/services/essays.ts | 22 +- packages/morph/services/notes.ts | 18 +- pnpm-lock.yaml | 63 + 30 files changed, 1593 insertions(+), 1546 deletions(-) delete mode 100644 packages/morph/app/template.tsx delete mode 100644 packages/morph/components/landing/page-transition.tsx create mode 100644 packages/morph/context/loading.tsx diff --git a/packages/morph/app/globals.css b/packages/morph/app/globals.css index d9dd4a3..157ec87 100644 --- a/packages/morph/app/globals.css +++ b/packages/morph/app/globals.css @@ -1973,7 +1973,9 @@ canvas { max-width: 300px; min-width: 260px; width: auto; - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 5px 10px -5px rgba(0, 0, 0, 0.05); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.1), + 0 5px 10px -5px rgba(0, 0, 0, 0.05); } /* Hide the default CodeMirror search panel */ diff --git a/packages/morph/app/template.tsx b/packages/morph/app/template.tsx deleted file mode 100644 index 9ed21ed..0000000 --- a/packages/morph/app/template.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client" - -import { AnimatePresence } from "motion/react" - -export default function Template({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} diff --git a/packages/morph/app/vaults/[vault]/page.tsx b/packages/morph/app/vaults/[vault]/page.tsx index d3645d7..a274441 100644 --- a/packages/morph/app/vaults/[vault]/page.tsx +++ b/packages/morph/app/vaults/[vault]/page.tsx @@ -1,49 +1,13 @@ "use client" -import { motion } from "motion/react" import { useParams } from "next/navigation" -import { useCallback, useEffect } from "react" +import { useEffect } from "react" import Editor from "@/components/editor" import { FilePreloader } from "@/context/providers" import { useVaultContext } from "@/context/vault" -// Define page transition variants -const pageVariants = { - initial: { - opacity: 0, - scale: 0.98, - }, - animate: { - opacity: 1, - scale: 1, - transition: { - duration: 0.4, - ease: [0.25, 0.1, 0.25, 1], - staggerChildren: 0.1, - }, - }, - exit: { - opacity: 0, - scale: 0.98, - transition: { - duration: 0.3, - ease: [0.25, 0.1, 0.25, 1], - }, - }, -}; - -// Define child element variants for staggered animation -const childVariants = { - initial: { opacity: 0, y: 10 }, - animate: { - opacity: 1, - y: 0, - transition: { duration: 0.3 } - }, -}; - export default function VaultPage() { const params = useParams() const vaultId = params.vault as string @@ -56,27 +20,12 @@ export default function VaultPage() { } }, [vaultId]) - const MemoizedEditor = useCallback( - () => , - [vaultId, vaults], - ) - return ( - - +
+
- - - + +
+
) } diff --git a/packages/morph/app/vaults/page.tsx b/packages/morph/app/vaults/page.tsx index d747687..ca94498 100644 --- a/packages/morph/app/vaults/page.tsx +++ b/packages/morph/app/vaults/page.tsx @@ -56,7 +56,14 @@ const Notch = memo(function Notch({ onClickBanner }: { onClickBanner: () => void }} > - + {/* Combine multiple drop shadows into a single filter for better performance */} diff --git a/packages/morph/components/author-processor.tsx b/packages/morph/components/author-processor.tsx index 778025f..7f8c6aa 100644 --- a/packages/morph/components/author-processor.tsx +++ b/packages/morph/components/author-processor.tsx @@ -1,7 +1,8 @@ -import { useAuthorTasks } from "@/context/authors" import { useQueryAuthorStatus } from "@/services/authors" import { useEffect } from "react" +import { useAuthorTasks } from "@/context/authors" + export function AuthorProcessor() { const { pendingTaskIds, getFileIdForTask, removeTask } = useAuthorTasks() diff --git a/packages/morph/components/dnd.tsx b/packages/morph/components/dnd.tsx index 83c7362..bc1a188 100644 --- a/packages/morph/components/dnd.tsx +++ b/packages/morph/components/dnd.tsx @@ -1,6 +1,5 @@ import { cn } from "@/lib" import { NOTES_DND_TYPE } from "@/lib/notes" -import { motion } from "motion/react" import { memo, useCallback, useEffect, useRef } from "react" import { useDragLayer, useDrop } from "react-dnd" @@ -64,12 +63,9 @@ export const EditorDropTarget = memo(function EditorDropTarget({ handleDroppedRef.current = handleNoteDropped }, [handleNoteDropped]) - const onDropped = useCallback( - (item: Note) => { - handleNoteDropped(item) - }, - [handleNoteDropped], - ) + const onDropped = useCallback((item: Note) => { + handleDroppedRef.current?.(item) + }, []) const [{ isOver }, drop] = useDrop( () => ({ @@ -104,36 +100,3 @@ export const EditorDropTarget = memo(function EditorDropTarget({
) }) - -interface PlayspaceProps { - children: React.ReactNode - vaultId: string -} - -export const Playspace = memo(function Playspace({ children, vaultId }: PlayspaceProps) { - return ( - - {children} - - ) -}) diff --git a/packages/morph/components/editor.tsx b/packages/morph/components/editor.tsx index 1acd741..33b8be4 100644 --- a/packages/morph/components/editor.tsx +++ b/packages/morph/components/editor.tsx @@ -37,11 +37,11 @@ import * as React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { DndProvider } from "react-dnd" import { HTML5Backend } from "react-dnd-html5-backend" +import { useDebouncedCallback } from "use-debounce" import { AuthorProcessor } from "@/components/author-processor" import ContextNotes from "@/components/context-notes" -import { CustomDragLayer, EditorDropTarget, Playspace } from "@/components/dnd" -import { EmbeddingStatus } from "@/components/embedding-status" +import { CustomDragLayer, EditorDropTarget } from "@/components/dnd" import { EssayEmbeddingProcessor } from "@/components/essay-embedding-processor" import { fileField, mdToHtml } from "@/components/markdown-inline" import { setFile } from "@/components/markdown-inline" @@ -60,6 +60,7 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { usePGlite } from "@/context/db" import { useEmbeddingTasks, useEssayEmbeddingTasks } from "@/context/embedding" import { useRestoredFile } from "@/context/file-restoration" +import { LoadingProvider, useLoading } from "@/context/loading" import { SearchProvider } from "@/context/search" import { SteeringProvider, SteeringSettings } from "@/context/steering" import { useVaultContext } from "@/context/vault" @@ -72,6 +73,103 @@ import { useToast } from "@/hooks/use-toast" import type { FileSystemTreeNode, Note, Vault } from "@/db/interfaces" import * as schema from "@/db/schema" +// After all the imports but before the logNotesForFile function, add the synchronizer component: + +/** + * Component to sync editor loading state with the loading context + */ +interface LoadingContextSynchronizerProps { + isNotesLoading: boolean + isNotesRecentlyGenerated: boolean +} + +function LoadingContextSynchronizer({ + isNotesLoading, + isNotesRecentlyGenerated, +}: LoadingContextSynchronizerProps) { + const { setNotesLoading, setNotesRecentlyGenerated } = useLoading() + + // Sync state to context + useEffect(() => { + setNotesLoading(isNotesLoading) + }, [isNotesLoading, setNotesLoading]) + + useEffect(() => { + setNotesRecentlyGenerated(isNotesRecentlyGenerated) + }, [isNotesRecentlyGenerated, setNotesRecentlyGenerated]) + + return null +} + +// Add this utility function after the imports but before the Editor component +/** + * Utility to query and log all notes for a specific file + */ +async function logNotesForFile( + db: any, + fileId: string, + vaultId: string, + label: string = "Notes query", +) { + if (process.env.NODE_ENV !== "development") return + + try { + console.log(`[Notes Debug] ${label} - Querying all notes for file ${fileId}`) + + // Query all notes for this file + const fileNotes = await db + .select() + .from(schema.notes) + .where(and(eq(schema.notes.fileId, fileId), eq(schema.notes.vaultId, vaultId))) + + const regularNotes = fileNotes.filter((note: any) => !note.dropped) + const droppedNotes = fileNotes.filter((note: any) => note.dropped) + + console.log(`[Notes Debug] ${label} - Found ${fileNotes.length} total notes:`) + console.log( + `[Notes Debug] ${label} - Regular notes: ${regularNotes.length}, Dropped notes: ${droppedNotes.length}`, + ) + + // Query all reasonings associated with these notes + const reasoningIds = [ + ...new Set(fileNotes.map((note: any) => note.reasoningId).filter(Boolean)), + ] as string[] // Explicitly type as string[] + + if (reasoningIds.length > 0) { + console.log(`[Notes Debug] ${label} - Found ${reasoningIds.length} unique reasoning IDs`) + + const reasonings = await db + .select() + .from(schema.reasonings) + .where(inArray(schema.reasonings.id, reasoningIds)) + + console.log( + `[Notes Debug] ${label} - Retrieved ${reasonings?.length || 0} reasonings from DB`, + ) + + // Log a summary of each reasoning and its notes + for (const reasoning of reasonings) { + const notesForReasoning = fileNotes.filter((n: any) => n.reasoningId === reasoning.id) + console.log( + `[Notes Debug] ${label} - Reasoning ${reasoning.id} has ${notesForReasoning.length} notes:`, + notesForReasoning.map((n: any) => ({ + id: n.id, + dropped: n.dropped, + embeddingStatus: n.embeddingStatus, + })), + ) + } + } else { + console.log(`[Notes Debug] ${label} - No reasoning IDs found`) + } + + return { fileNotes, regularNotes, droppedNotes } + } catch (error) { + console.error(`[Notes Debug] ${label} - Error querying notes:`, error) + return { fileNotes: [], regularNotes: [], droppedNotes: [] } + } +} + interface EditorProps { vaultId: string vaults: Vault[] @@ -99,6 +197,22 @@ export interface AuthorRequest { num_search_results?: number } +function saveLastFileInfo(vaultId: string | null, fileId: string, handleId: string) { + if (!vaultId || fileId === null) return + try { + localStorage.setItem( + `morph:last-file:${vaultId}`, + JSON.stringify({ + lastAccessed: new Date().toISOString(), + handleId, + fileId, + }), + ) + } catch (storageError) { + console.error("Failed to save file info to localStorage:", storageError) + } +} + // Replace the wrapper component with a direct export of EditorComponent export default memo(function Editor({ vaultId, vaults }: EditorProps) { const { theme } = useTheme() @@ -108,7 +222,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const { settings } = usePersistedSettings() const { refreshVault, flattenedFileIds } = useVaultContext() - const [currentFile, setCurrentFile] = useState("Untitled") const [isEditMode, setIsEditMode] = useState(true) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [previewNode, setPreviewNode] = useState(null) @@ -140,63 +253,64 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const [vimMode, setVimMode] = useState(settings.vimMode ?? false) // Add a ref to track if we've already attempted to restore a file const fileRestorationAttempted = useRef(false) - // Add state to show vim mode change notification - const [showVimModeChangeNotification, setShowVimModeChangeNotification] = useState(false) + // Add a state for currentFileId that allows null values const [currentFileId, setCurrentFileId] = useState(null) // Add a state for visible context note IDs (around line 150, with other state declarations) const [visibleContextNoteIds, setVisibleContextNoteIds] = useState([]) // Add state for author understanding indicator const [isAuthorProcessing, setIsAuthorProcessing] = useState(false) - // Add a ref to track loaded files to prevent duplicate note loading - const loadedFiles = useRef>(new Set()) - const client = usePGlite() const db = drizzle({ client, schema }) const vault = vaults.find((v) => v.id === vaultId) // Add the process pending embeddings mutation - const processEmbeddings = useProcessPendingEmbeddings() + const processEmbeddings = useProcessPendingEmbeddings(db) // Add the process pending embeddings mutation for essays - const processEssayEmbeddings = useProcessPendingEssayEmbeddings() + const processEssayEmbeddings = useProcessPendingEssayEmbeddings(db) - // Get the task actions for adding tasks - const { addTask, pendingTaskIds } = useEmbeddingTasks() - const { addTask: addEssayTask, pendingTaskIds: essayPendingTaskIds } = useEssayEmbeddingTasks() + // create a ref for contents to batch updates + const contentRef = useRef({ content: "" }) - const toggleStackExpand = useCallback(() => { - setIsStackExpanded((prev) => !prev) - }, []) + // Create a ref for the keyboard handler to ensure stable identity + const keyHandlerRef = useRef<(e: KeyboardEvent) => void>(() => {}) - // Add a ref to track initial settings load - const initialSettingsLoadComplete = useRef(false) + // Add this ref to track if we've processed embeddings for the current file + const embeddingProcessedRef = useRef(null) + + // Add a ref to track if we've processed essay embeddings for the current file + const essayEmbeddingProcessedRef = useRef(null) + + // Use a ref to store the timeout ID so we can access it in the cleanup function + const embeddingTimeoutRef = useRef(null) + + // Get the task actions for adding tasks + const { addTask /*pendingTaskIds*/ } = useEmbeddingTasks() + const { addTask: addEssayTask /*pendingTaskIds: essayPendingTaskIds*/ } = useEssayEmbeddingTasks() - // Effect to update vimMode state when settings change useEffect(() => { - // Only show notification if this isn't the initial settings load - if (settings.vimMode !== vimMode) { - setVimMode(settings.vimMode ?? false) + if (restoredFile && restoredFile!.fileId) setCurrentFileId(restoredFile!.fileId) + }, [restoredFile]) - // Only show notification if initial settings have already been loaded once - if (initialSettingsLoadComplete.current) { - setShowVimModeChangeNotification(true) - } else { - // Mark that we've completed the initial settings load - initialSettingsLoadComplete.current = true - } - } - }, [settings.vimMode, vimMode]) + // Define updateContentRef function that updates content without logging + const updateContentRef = useCallback(() => { + contentRef.current = { content: markdownContent } + }, [markdownContent]) + + const toggleStackExpand = useCallback(() => { + setIsStackExpanded((prev) => !prev) + }, []) const updatePreview = useCallback( - async (value: string) => { + async (content: string) => { try { const tree = await mdToHtml({ - value, + value: content, settings, vaultId, - filename: currentFile, + fileid: currentFileId, returnHast: true, }) setPreviewNode(tree) @@ -204,34 +318,85 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { console.error(error) } }, - [currentFile, settings, vaultId], + [currentFileId, settings, vaultId], ) + // Debounce the preview update function + const debouncedUpdatePreview = useDebouncedCallback(updatePreview, 300) // 300ms debounce delay + // Helper function to load file content and UI state const loadFileContent = useCallback( - (fileName: string, fileHandle: FileSystemFileHandle, content: string) => { + async (fileId: string, fileHandle: FileSystemFileHandle, content: string) => { if (!codeMirrorViewRef.current) return false try { - // Update CodeMirror codeMirrorViewRef.current.dispatch({ changes: { from: 0, to: codeMirrorViewRef.current.state.doc.length, insert: content, }, - effects: setFile.of(fileName), + effects: setFile.of(fileId), }) // Update state setCurrentFileHandle(fileHandle) - setCurrentFile(fileName) + setCurrentFileId(fileId) setMarkdownContent(content) setHasUnsavedChanges(false) setIsEditMode(true) // Update preview - updatePreview(content) + debouncedUpdatePreview(content) + + // If this is not a new file, look up its ID in the database + if (vault) { + try { + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), + }) + + if (dbFile) { + // Only set current file id if we didn't already have it (important to avoid state conflicts) + if (dbFile.id !== fileId) { + setCurrentFileId(dbFile.id) + } + } else { + // If file doesn't exist in the database yet, create it + const extension = fileHandle.name.includes(".") + ? fileHandle.name.split(".").pop() || "" + : "" + + try { + const newFile = await db + .insert(schema.files) + .values({ + id: fileId, + name: fileHandle.name, + extension, + vaultId: vaultId, + lastModified: new Date(), + embeddingStatus: "in_progress", + }) + .returning() + + if (newFile && newFile.length > 0) { + setCurrentFileId(newFile[0].id) + } + } catch (dbError) { + console.error("Error creating file in database:", dbError) + // Fall back to using provided id + setCurrentFileId(fileId) + } + } + } catch (error) { + console.error("Error finding file in database:", error) + // Fall back to using provided id + setCurrentFileId(fileId) + } + } else { + setCurrentFileId(fileId) + } return true } catch (error) { @@ -239,44 +404,26 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { return false } }, - [updatePreview], + [debouncedUpdatePreview, vaultId, vault, db], ) // Helper function to load file metadata (notes, reasonings, etc.) const loadFileMetadata = useCallback( - async (fileName: string) => { - if (!vault) return + async (fileId: string) => { + if (!vault || fileId === null) return try { + console.log(`[Notes Debug] Starting to load metadata for file ${fileId}`) + // Find the file in database - let dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => and(eq(files.name, fileName), eq(files.vaultId, vault.id)), + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), }) // If file doesn't exist in DB, create it if (!dbFile) { - // Extract extension - const extension = fileName.includes(".") ? fileName.split(".").pop() || "" : "" - - try { - // Insert file into database - await db.insert(schema.files).values({ - name: fileName, - extension, - vaultId: vault.id, - lastModified: new Date(), - embeddingStatus: "in_progress", - }) - - // Re-fetch the file to get its ID - dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, fileName), eq(files.vaultId, vault.id)), - }) - } catch (dbError) { - console.error("Error inserting file into database:", dbError) - return // Exit early if we can't create the file - } + console.error(`File ${fileId} does not exists in db. Something has gone very wrong`) + return } // Only continue if we have a valid file reference @@ -291,7 +438,11 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const fileNotes = await db .select() .from(schema.notes) - .where(and(eq(schema.notes.fileId, dbFile.id), eq(schema.notes.vaultId, vault.id))) + .where(and(eq(schema.notes.fileId, dbFile.id), eq(schema.notes.vaultId, vaultId))) + + console.log( + `[Notes Debug] Retrieved ${fileNotes?.length || 0} notes from DB for file ${fileId}`, + ) if (fileNotes && fileNotes.length > 0) { // Process notes and reasoning in parallel @@ -299,6 +450,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const regularNotes = fileNotes.filter((note) => !note.dropped) const droppedNotesList = fileNotes.filter((note) => note.dropped) + console.log( + `[Notes Debug] Regular notes: ${regularNotes.length}, Dropped notes: ${droppedNotesList.length}`, + ) + // Prepare notes for the UI const uiReadyRegularNotes = regularNotes.map((note) => ({ ...note, @@ -317,23 +472,26 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setCurrentlyGeneratingDateKey(null) setNotesError(null) - // Update the state with fetched notes atomically to prevent flicker - setNotes(uiReadyRegularNotes) - setDroppedNotes(uiReadyDroppedNotes) + let loadedReasoningHistory: ReasoningHistory[] = [] // In parallel, fetch and process reasoning if needed const reasoningIds = [ ...new Set(fileNotes.map((note) => note.reasoningId).filter(Boolean)), - ] + ] as string[] // Explicitly type as string[] if (reasoningIds.length > 0) { + console.log( + `[Notes Debug] Found ${reasoningIds.length} unique reasoning IDs, fetching reasoning`, + ) const reasonings = await db .select() .from(schema.reasonings) .where(inArray(schema.reasonings.id, reasoningIds)) + console.log(`[Notes Debug] Retrieved ${reasonings?.length || 0} reasonings from DB`) + if (reasonings && reasonings.length > 0) { // Convert to ReasoningHistory format - const reasoningHistory = reasonings.map((r) => ({ + loadedReasoningHistory = reasonings.map((r) => ({ id: r.id, content: r.content, timestamp: r.createdAt, @@ -344,30 +502,37 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { temperature: r.steering?.temperature, numSuggestions: r.steering?.numSuggestions, })) - - // Update reasoning history state atomically - setReasoningHistory(reasoningHistory) - } else { - // Reset reasoning history if no reasonings found - setReasoningHistory([]) } - } else { - // Reset reasoning history if no reasoning IDs - setReasoningHistory([]) } + + // Update the state with fetched notes atomically to prevent flicker + // Ensure we are using the correct variables here + setNotes(uiReadyRegularNotes) + setDroppedNotes(uiReadyDroppedNotes) + setReasoningHistory(loadedReasoningHistory) + + console.log(`[Notes Debug] State updated after loading metadata:`, { + notesCount: uiReadyRegularNotes.length, + droppedNotesCount: uiReadyDroppedNotes.length, + reasoningHistoryCount: loadedReasoningHistory.length, + }) } else { - // No notes found for this file, reset all note states - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) + console.log(`[Notes Debug] No notes found for file ${fileId}, resetting state`) + // Ensure state is reset even if no notes are found setNotes([]) setDroppedNotes([]) setReasoningHistory([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) } + // After loading and setting up notes, perform a detailed query and log for verification + await logNotesForFile(db, dbFile.id, vaultId, "After loadFileMetadata") + // Reset embedding processing flags for the new file - embeddingProcessedRef.current = false - essayEmbeddingProcessedRef.current = false + embeddingProcessedRef.current = null + essayEmbeddingProcessedRef.current = null } catch (error) { console.error("Error fetching notes for file:", error) // Reset note states on error @@ -384,157 +549,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { throw dbError } }, - [db, vault], - ) - - // Helper function to load file metadata with deduplication - const loadFileMetadataOnce = useCallback( - async (fileName: string) => { - // Create a unique key for this file - const fileKey = `${vaultId}:${fileName}` - - // Skip if we've already loaded this file - if (loadedFiles.current.has(fileKey)) return - - // Mark file as loaded before we start loading to prevent race conditions - loadedFiles.current.add(fileKey) - - try { - await loadFileMetadata(fileName) - } catch (error) { - // If loading fails, remove from the loaded set so we can try again - loadedFiles.current.delete(fileKey) - console.error(`[Debug] Error loading file metadata for ${fileName}:`, error) - } - }, - [vaultId, loadFileMetadata], + [db, vault, vaultId], ) - // Effect to use preloaded file from context if available - useEffect(() => { - if (!restoredFile || fileRestorationAttempted.current) return - - // Mark file as restored so we don't try again - fileRestorationAttempted.current = true - - // Update local state even before CodeMirror is ready - setCurrentFile(restoredFile.fileName) - setMarkdownContent(restoredFile.content) - setCurrentFileHandle(restoredFile.fileHandle) - setHasUnsavedChanges(false) - - // Update preview immediately with the restored content - updatePreview(restoredFile.content) - - // Create flags to track processing status - let embeddingProcessingStarted = false - - // If CodeMirror is ready, also update it - if (codeMirrorViewRef.current) { - loadFileContent(restoredFile.fileName, restoredFile.fileHandle, restoredFile.content) - - // Load file metadata - we'll reset note states only after we've loaded the metadata - if (vault && restoredFile.fileName !== "Untitled") { - // Load file metadata - ;(async () => { - try { - await loadFileMetadata(restoredFile.fileName) - - // Process essay embeddings for restored file (only if online) - // Use a small timeout to let the file load completely first - setTimeout(async () => { - try { - // Skip if processing has already started or if file is already processed - if (embeddingProcessingStarted || essayEmbeddingProcessedRef.current) { - console.log( - "[Debug] Skipping duplicate essay embedding processing for restored file", - ) - return - } - - // Mark as processing immediately - embeddingProcessingStarted = true - essayEmbeddingProcessedRef.current = true - - // Find the file in the database to get its ID - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, restoredFile.fileName), eq(files.vaultId, vault.id)), - }) - - if (dbFile) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) - - if (!hasEmbeddings) { - console.log("[Debug] Processing embeddings for restored file") - // Process essay embeddings asynchronously using the md content function - const content = md(restoredFile.content).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: content, - currentVaultId: vault.id, - currentFileId: dbFile.id, - }) - } else { - console.log("[Debug] Restored file already has embeddings") - } - - // Process any existing notes for this file (only once) - if (!embeddingProcessedRef.current) { - embeddingProcessedRef.current = true - processEmbeddings.mutate({ addTask }) - } - } - } catch (error) { - console.error("Error processing file data:", error) - // Reset flags on error to allow retrying - essayEmbeddingProcessedRef.current = false - embeddingProcessedRef.current = false - } - }, 1000) // 1 second delay to ensure DB operations are ready - } catch (error) { - console.error("Error loading file metadata:", error) - // If metadata loading fails, reset the notes state to avoid stale data - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - } - })() - } else { - // For new/unsaved files, we do want to reset the notes state - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - } - } - }, [ - setRestoredFile, - restoredFile, - loadFileContent, - loadFileMetadata, - vault, - db, - processEmbeddings, - processEssayEmbeddings, - addTask, - addEssayTask, - addAuthorTask, - updatePreview, - ]) - - // Reset loaded files cache when vault changes - useEffect(() => { - loadedFiles.current.clear() - }, [vaultId]) - - const toggleNotes = useCallback(() => { + const toggleNotes = useCallback(async () => { // Instead of using setState callback, directly use a variable for performance const shouldShowNotes = !showNotes @@ -547,6 +565,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Synchronize current generation notes with history if they exist if (currentGenerationNotes.length > 0 && currentlyGeneratingDateKey) { + console.log( + `[Notes Debug] Syncing ${currentGenerationNotes.length} current generation notes to DB on panel close`, + ) + // Process the notes synchronously, rather than in state updater // Filter out any duplicates that might already exist const notesToAdd = currentGenerationNotes.filter( @@ -554,6 +576,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) if (notesToAdd.length > 0) { + console.log(`[Notes Debug] Adding ${notesToAdd.length} new notes to state`) // Add to notes and sort by creation date const combined = [...notesToAdd, ...notes] const sorted = combined.sort( @@ -562,123 +585,141 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setNotes(sorted) } - // Save notes to database if the file isn't "Untitled" and we have a vault - if (currentFile !== "Untitled" && vault) { + // Save notes to database if the file isn't null and we have a vault + if (currentFileId !== null && vault) { // We use a self-executing async function to avoid making the whole callback async - ;(async () => { - try { - // First, find the file in database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault.id)), - }) + try { + console.log(`[Notes Debug] Saving notes to DB for file ${currentFileId}`) + // First, find the file in database + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => + and(eq(files.id, currentFileId), eq(files.vaultId, vaultId)), + }) - if (!dbFile) { - console.error("Failed to find file in database when synchronizing notes") - return - } + if (!dbFile) { + console.error("Failed to find file in database when synchronizing notes") + return + } - // Get the current reasoning for these notes - const currentReasoning = reasoningHistory.find((r) => r.id === currentReasoningId) - if (currentReasoning && currentReasoningId) { - // Check if reasoning exists in database - const existingReasoning = await db.query.reasonings.findFirst({ - where: eq(schema.reasonings.id, currentReasoningId), - }) + // Get the current reasoning for these notes + const currentReasoning = reasoningHistory.find((r) => r.id === currentReasoningId) + if (currentReasoning && currentReasoningId) { + // Check if reasoning exists in database + const existingReasoning = await db.query.reasonings.findFirst({ + where: eq(schema.reasonings.id, currentReasoningId), + }) - // If reasoning doesn't exist yet, create it - if (!existingReasoning && currentReasoning) { - // Get note IDs for the reasoning - const noteIds = currentGenerationNotes.map((note) => note.id) - - // Insert reasoning with properly typed steering (can be null) - await db.insert(schema.reasonings).values({ - id: currentReasoningId, - fileId: dbFile.id, - vaultId: vault.id, - content: currentReasoning.content, - noteIds: noteIds, - createdAt: currentReasoning.timestamp, - accessedAt: new Date(), - duration: currentReasoning.reasoningElapsedTime, - steering: currentGenerationNotes[0]?.steering || null, - }) - } - } + // If reasoning doesn't exist yet, create it + if (!existingReasoning && currentReasoning) { + // Get note IDs for the reasoning + const noteIds = currentGenerationNotes.map((note) => note.id) - // For each note, ensure it's saved to database if not already - for (const note of currentGenerationNotes) { - // Check if note exists in database - const existingNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) + console.log( + `[Notes Debug] Creating new reasoning in DB with ID ${currentReasoningId} and ${noteIds.length} note IDs`, + ) - if (!existingNote) { - // Insert note with properly typed steering (can be null) - await db.insert(schema.notes).values({ - id: note.id, - content: note.content, - color: note.color, - createdAt: note.createdAt, - accessedAt: new Date(), - dropped: note.dropped ?? false, - fileId: dbFile.id, - vaultId: note.vaultId, - reasoningId: note.reasoningId!, - steering: note.steering || null, - embeddingStatus: "in_progress", - embeddingTaskId: null, - }) - } + // Insert reasoning with properly typed steering (can be null) + await db.insert(schema.reasonings).values({ + id: currentReasoningId, + fileId: dbFile.id, + vaultId: vaultId, + content: currentReasoning.content, + noteIds: noteIds, + createdAt: currentReasoning.timestamp, + accessedAt: new Date(), + duration: currentReasoning.reasoningElapsedTime, + steering: currentGenerationNotes[0]?.steering || null, + }) + } else { + console.log( + `[Notes Debug] Reasoning ${currentReasoningId} already exists in DB, skipping creation`, + ) } + } - // Process notes for embedding one by one and set up polling - for (const note of currentGenerationNotes) { - const hasEmbedding = await checkNoteHasEmbedding(db, note.id) - if (!hasEmbedding) { - const result = await submitNoteForEmbedding(db, note) - - // If successful and we have a task ID, add it for polling - if (result) { - // Wait a small amount of time to ensure DB updates are complete - await new Promise((resolve) => setTimeout(resolve, 100)) + // For each note, ensure it's saved to database if not already + for (const note of currentGenerationNotes) { + // Check if note exists in database + const existingNote = await db.query.notes.findFirst({ + where: eq(schema.notes.id, note.id), + }) - const updatedNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) + if (!existingNote) { + console.log(`[Notes Debug] Saving new note to DB: ${note.id}`) - if (updatedNote?.embeddingTaskId) { - addTask(updatedNote.embeddingTaskId) - } - } - } + // Insert note with properly typed steering (can be null) + await db.insert(schema.notes).values({ + id: note.id, + content: note.content, + color: note.color, + createdAt: note.createdAt, + accessedAt: new Date(), + dropped: note.dropped ?? false, + fileId: dbFile.id, + vaultId: note.vaultId, + reasoningId: note.reasoningId!, + steering: note.steering || null, + embeddingStatus: "in_progress", + embeddingTaskId: null, + }) + } else { + console.log(`[Notes Debug] Note ${note.id} already exists in DB, skipping creation`) } - } catch (error) { - console.error("Failed to sync notes to database:", error) } - })() - } - // Reset current generation state to prevent duplication when reopening - setCurrentGenerationNotes([]) + // Process notes for embedding one by one and set up polling + for (const note of currentGenerationNotes) { + const hasEmbedding = await checkNoteHasEmbedding(db, note.id) + if (!hasEmbedding) { + console.log(`[Notes Debug] Submitting note ${note.id} for embedding`) + const result = await submitNoteForEmbedding(db, note) + + // If successful and we have a task ID, add it for polling + if (result) { + const updatedNote = await db.query.notes.findFirst({ + where: eq(schema.notes.id, note.id), + }) + + if (updatedNote?.embeddingTaskId) { + addTask(updatedNote.embeddingTaskId) + console.log( + `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, + ) + } + } + } else { + console.log( + `[Notes Debug] Note ${note.id} already has embedding, skipping submission`, + ) + } + } + } catch (error) { + console.error("Failed to sync notes to database:", error) + } + } + + // Reset current generation state to prevent duplication when reopening + setCurrentGenerationNotes([]) setCurrentlyGeneratingDateKey(null) } } else { // When opening the notes panel, check for notes that need embeddings // but only process once per file to avoid excessive processing if (notes.length > 0 && !embeddingProcessedRef.current) { + console.log(`[Notes Debug] Processing embeddings for ${notes.length} notes`) processEmbeddings.mutate({ addTask }) - embeddingProcessedRef.current = true + embeddingProcessedRef.current = `${vault?.id || ""}:${currentFileId}` } } // Update state after all synchronous operations setShowNotes(shouldShowNotes) }, [ + vaultId, + vault, currentGenerationNotes, currentlyGeneratingDateKey, - currentFile, - vault, + currentFileId, db, currentReasoningId, reasoningHistory, @@ -688,10 +729,11 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { showNotes, ]) - const contentRef = useRef({ content: "", filename: "" }) - const handleNoteDropped = useCallback( async (note: Note) => { + if (currentFileId === null) return + console.log(`[Notes Debug] Handling note drop for note ${note.id}`) + // Ensure note has a color if it doesn't already const noteWithColor = { ...note, @@ -714,7 +756,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // First, find the file in the database const dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault?.id || "")), + and(eq(files.id, currentFileId!), eq(files.vaultId, vault?.id || "")), }) if (!dbFile) { @@ -722,6 +764,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { return } + console.log(`[Notes Debug] Updating note ${note.id} in DB to mark as dropped`) + // Save to database - update the note's dropped flag await db .update(schema.notes) @@ -735,10 +779,14 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { }) .where(eq(schema.notes.id, noteWithColor.id)) + console.log(`[Notes Debug] Successfully updated note ${note.id} as dropped in DB`) + // After updating the note's dropped status, check if it needs embedding // If it doesn't already have an embedding, submit it const hasEmbedding = await checkNoteHasEmbedding(db, noteWithColor.id) if (!hasEmbedding) { + console.log(`[Notes Debug] Note ${note.id} needs embedding, submitting...`) + // Get the full note with all fields const fullNote = await db.query.notes.findFirst({ where: eq(schema.notes.id, noteWithColor.id), @@ -760,6 +808,9 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { if (updatedNote?.embeddingTaskId) { addTask(updatedNote.embeddingTaskId) + console.log( + `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, + ) // Update dropped notes with the new status and task ID setDroppedNotes((prev) => @@ -775,6 +826,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) } } else { + console.log(`[Notes Debug] Failed to submit note ${note.id} for embedding`) + // Update dropped notes to show failure setDroppedNotes((prev) => prev.map((n) => @@ -784,6 +837,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } } } else { + console.log(`[Notes Debug] Note ${note.id} already has embedding, marking as success`) + // Update dropped notes to show success setDroppedNotes((prev) => prev.map((n) => (n.id === noteWithColor.id ? { ...n, embeddingStatus: "success" } : n)), @@ -797,25 +852,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) } }, - [db, currentFile, vault, addTask], - ) - - useEffect(() => { - contentRef.current = { content: markdownContent, filename: currentFile } - }, [markdownContent, currentFile]) - - const onContentChange = useCallback( - async (value: string) => { - setMarkdownContent(value) - if (value !== markdownContent) { - setHasUnsavedChanges(true) - } - - const timeoutId = setTimeout(() => updatePreview(value), 100) - - return () => clearTimeout(timeoutId) - }, - [updatePreview, markdownContent], + [db, currentFileId, vault, addTask], ) const fetchNewNotes = useCallback( @@ -1275,11 +1312,22 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const handleSave = useCallback(async () => { try { let targetHandle = currentFileHandle + let fileId = currentFileId + const handleId = createId() // Always generate a new handle ID + let isNewFile = false + + // If we're working with the default file or don't have a handle, we need to save as a new file + if (!targetHandle || fileId === null) { + isNewFile = true + // For new files, create a new unique file ID if we're using null + if (fileId === null) { + fileId = createId() + } - if (!targetHandle) { + // Show the save file picker targetHandle = await window.showSaveFilePicker({ id: vaultId, - suggestedName: currentFile.endsWith(".md") ? currentFile : `${currentFile}.md`, + suggestedName: `morph-${fileId}.md`, types: [ { description: "Markdown Files", @@ -1289,90 +1337,168 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { }) } + // Write the file content const writable = await targetHandle.createWritable() await writable.write(markdownContent) await writable.close() - // When saving a new file, we need to get a handle ID for it - const handleId = createId() // Generate a new ID for this handle + const file = await targetHandle.getFile() - if (!currentFileHandle && vault) { - // Store the handle in IndexedDB with the new ID - await storeHandle(handleId, vaultId, handleId, targetHandle) + // If this is a new file and we have a vault, create a db entry + if (isNewFile && vault) { + // Extract the filename and extension + const filename = targetHandle.name + const extension = filename.includes(".") ? filename.split(".").pop() || "" : "" - setCurrentFileHandle(targetHandle) - setCurrentFile(targetHandle.name) + try { + // Create a new file entry in the database + const newFile = await db + .insert(schema.files) + .values({ + id: fileId, + name: filename, + extension, + vaultId: vaultId, + lastModified: new Date(), + embeddingStatus: "in_progress", + }) + .returning() - await refreshVault(vault.id) - } + if (newFile && newFile.length > 0) { + // Use the database-generated ID if available + const dbFileId = newFile[0].id - // Save the current file info to localStorage for this vault - if (vaultId) { + // Store the handle in IndexedDB with the new ID + await storeHandle(handleId, vaultId, dbFileId, targetHandle) + + // Update component state with the new file information + setCurrentFileId(dbFileId) + setCurrentFileHandle(targetHandle) + + // Refresh the vault to show the new file + await refreshVault(vaultId) + + // Update fileId to use the DB one for localStorage + fileId = dbFileId + } + } catch (dbError) { + console.error("Error creating file in database:", dbError) + // If database save fails, still update local state + setCurrentFileId(fileId) + setCurrentFileHandle(targetHandle) + + // Store the handle even if DB fails + await storeHandle(handleId, vaultId, fileId, targetHandle) + } + } else if (currentFileId !== null) { + // Update last modified time for existing files try { - const fileName = targetHandle.name - localStorage.setItem( - `morph:last-file:${vaultId}`, - JSON.stringify({ - fileName, - lastAccessed: new Date().toISOString(), - handleId: handleId, // Use the newly created handleId - }), - ) - } catch (storageError) { - console.error("Failed to save file info to localStorage:", storageError) - // Non-critical, we can continue + await db + .update(schema.files) + .set({ lastModified: new Date() }) + .where( + and(eq(schema.files.id, currentFileId), eq(schema.files.vaultId, vault?.id || "")), + ) + } catch (error) { + console.error("Error updating file last modified time:", error) } } + // Save the current file info to localStorage + saveLastFileInfo(vaultId, fileId, handleId) + + // Update the restored file context + setRestoredFile({ + file, + fileHandle: targetHandle, + fileId, + content: markdownContent, + handleId, + }) + + // Update the content reference only after successful save + updateContentRef() + + // Reset unsaved changes flag setHasUnsavedChanges(false) - } catch {} - }, [currentFileHandle, markdownContent, currentFile, vault, refreshVault, vaultId, storeHandle]) + } catch (error) { + console.error("Error saving file:", error) + } + }, [ + setRestoredFile, + currentFileHandle, + markdownContent, + vault, + refreshVault, + vaultId, + storeHandle, + currentFileId, + db, + updateContentRef, + ]) - const memoizedExtensions = useMemo(() => { + const baseExtensions = useMemo(() => { const tabSize = new Compartment() - const exts = [ + return [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), markdown({ base: markdownLanguage, codeLanguages: languages }), frontmatter(), EditorView.lineWrapping, tabSize.of(EditorState.tabSize.of(settings.tabSize)), - fileField.init(() => currentFile), + fileField.init(() => currentFileId), EditorView.updateListener.of((update) => { if (update.docChanged || update.selectionSet) { // We only update the filename if it explicitly changes via the effect - const newFilename = update.state.field(fileField) - setCurrentFile(newFilename) + const newFileId = update.state.field(fileField) + setCurrentFileId(newFileId) } }), syntaxHighlighting(), search(), hyperLink, ] + }, [settings.tabSize, currentFileId]) - if (vimMode) exts.push(vim()) - return exts - }, [settings, currentFile, vimMode]) - - useEffect(() => { - if (restoredFile?.content) { - updatePreview(restoredFile.content) - } else if (markdownContent) { - updatePreview(markdownContent) - } - }, [markdownContent, updatePreview, restoredFile]) + // Only compute extensions that will be used in the editor + const memoizedExtensions = useMemo(() => { + return vimMode ? [...baseExtensions, vim()] : baseExtensions + }, [baseExtensions, vimMode]) + + // Memoize the basicSetup object + const memoizedBasicSetup = useMemo( + () => ({ + rectangularSelection: false, + indentOnInput: true, + syntaxHighlighting: true, + highlightActiveLine: true, + highlightSelectionMatches: true, + }), + [], + ) const onNewFile = useCallback(() => { setCurrentFileHandle(null) - setCurrentFile("Untitled") setMarkdownContent("") // Clear content for new file setIsEditMode(true) setPreviewNode(null) // Clear preview - setNotes([]) // Clear notes associated with previous file - setDroppedNotes([]) // Clear dropped notes - setReasoningHistory([]) // Clear reasoning history + + // Reset all note states for new file + setNotes([]) + setDroppedNotes([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setReasoningHistory([]) + + // Reset embedding processing flags + embeddingProcessedRef.current = null + essayEmbeddingProcessedRef.current = null + setHasUnsavedChanges(false) // Reset unsaved changes + // Set currentFileId to null for new file + setCurrentFileId(null) // Clear the last file info from localStorage if (vaultId) { @@ -1389,51 +1515,22 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setIsSettingsOpen((prev) => !prev) }, []) - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - // Special handling for settings shortcut - high priority - if ((event.metaKey || event.ctrlKey) && event.key === ",") { - event.preventDefault() - event.stopPropagation() // Stop event from bubbling up - toggleSettings() - return - } - - // Other keyboard shortcuts - if (event.key === settings.toggleNotes && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - toggleNotes() - } else if (event.key === settings.toggleEditMode && (event.metaKey || event.altKey)) { - event.preventDefault() - setIsEditMode((prev) => !prev) - } else if ((event.ctrlKey || event.metaKey) && event.key === "s") { - event.preventDefault() - handleSave() - } - }, - [handleSave, toggleNotes, settings, toggleSettings], - ) - - // Separate effect specifically for global keyboard shortcuts, and vim mode - useEffect(() => { - Vim.defineEx("w", "w", handleSave) - Vim.defineEx("wa", "w", handleSave) - Vim.map(";", ":", "normal") - Vim.map("jj", "", "insert") - Vim.map("jk", "", "insert") - - const handler = (e: KeyboardEvent) => handleKeyDown(e) - window.addEventListener("keydown", handler, { capture: true }) // Use capture to get event before other handlers - - return () => window.removeEventListener("keydown", handler, { capture: true }) - }, [handleKeyDown, handleSave]) - const generateNewSuggestions = useCallback( async (steeringSettings: SteeringSettings) => { - if (!currentFile || !vault || !markdownContent) return + if (!vault || !markdownContent) return + + console.log(`[Notes Debug] Starting to generate new suggestions with settings:`, { + numSuggestions: steeringSettings.numSuggestions, + hasAuthors: !!steeringSettings.authors, + temperature: steeringSettings.temperature, + hasTonality: steeringSettings.tonalityEnabled, + }) // Move current generation notes to history by adding them to notes array without the currentGenerationNotes flag if (currentGenerationNotes.length > 0) { + console.log( + `[Notes Debug] Moving ${currentGenerationNotes.length} current generation notes to history`, + ) // First ensure the current notes are in the main notes array (if they aren't already) setNotes((prev) => { // Filter out any notes that might already exist in the array @@ -1443,6 +1540,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { if (notesToAdd.length === 0) return prev + console.log(`[Notes Debug] Adding ${notesToAdd.length} notes to main notes array`) // Add to notes and sort const combined = [...notesToAdd, ...prev] return combined.sort( @@ -1454,6 +1552,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Clear current generation notes before starting new generation setCurrentGenerationNotes([]) setNotesError(null) + // Update loading state via setState setIsNotesLoading(true) setStreamingReasoning("") setReasoningComplete(false) @@ -1470,18 +1569,20 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // First, find or create the file in the database to ensure proper ID reference let dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault.id)), + and(eq(files.id, currentFileId!), eq(files.vaultId, vaultId)), }) - if (!dbFile) { - // Extract extension - const extension = currentFile.includes(".") ? currentFile.split(".").pop() || "" : "" + console.log(`[Notes Debug] Found file in DB: ${dbFile?.id || "not found"}`) + if (!dbFile) { + console.log( + `[Notes Debug] File not found in DB, creating new file entry for ${currentFileHandle?.name}`, + ) // Insert file into database await db.insert(schema.files).values({ - name: currentFile, - extension, - vaultId: vault.id, + name: currentFileHandle!.name, + extension: "md", + vaultId: vaultId, lastModified: new Date(), embeddingStatus: "in_progress", }) @@ -1489,16 +1590,18 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Re-fetch the file to get its ID dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault.id)), + and(eq(files.id, currentFileId!), eq(files.vaultId, vaultId)), }) if (!dbFile) { throw new Error("Failed to create file in database") } + console.log(`[Notes Debug] Created new file in DB with ID: ${dbFile.id}`) } try { // Now that we have the file, generate the suggestions + console.log(`[Notes Debug] Fetching new notes from API`) const { generatedNotes, reasoningId, reasoningElapsedTime, reasoningContent } = await fetchNewNotes( markdownContent, @@ -1514,6 +1617,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { : undefined, ) + console.log( + `[Notes Debug] Received ${generatedNotes.length} generated notes with reasoning ID: ${reasoningId}`, + ) + const newNoteIds: string[] = [] const newNotes: Note[] = generatedNotes.map((note, index) => { const id = createId() @@ -1523,7 +1630,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { content: note.content, color: streamingSuggestionColors[index] || generatePastelColor(), fileId: dbFile!.id, // Use the actual DB file ID here - vaultId: vault.id, + vaultId: vaultId, isInEditor: false, createdAt: new Date(), lastModified: new Date(), @@ -1539,52 +1646,65 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } }) - // Add note IDs to reasoning history - setReasoningHistory((prev) => - prev.map((r) => (r.id === reasoningId ? { ...r, noteIds: newNoteIds } : r)), - ) + // Prepare the reasoning record to be saved + const reasoningRecordToSave = { + id: reasoningId, + fileId: dbFile!.id, + vaultId: vaultId, + content: reasoningContent, + noteIds: newNoteIds, + createdAt: now, + accessedAt: now, + duration: reasoningElapsedTime, + steering: { + authors: steeringSettings.authors, + tonality: steeringSettings.tonalityEnabled ? steeringSettings.tonality : undefined, + temperature: steeringSettings.temperature, + numSuggestions: steeringSettings.numSuggestions, + }, + } - // Only save if the file is not "Untitled" and we have notes to save - if (currentFile !== "Untitled" && vault && newNotes.length > 0) { + // Only save if the file is not null and we have notes to save + if (currentFileId !== null && vault && newNotes.length > 0) { try { - await db.insert(schema.reasonings).values({ - id: reasoningId, - fileId: dbFile!.id, - vaultId: vault.id, - content: reasoningContent, - noteIds: newNoteIds, - createdAt: now, - accessedAt: now, - duration: reasoningElapsedTime, - steering: { - authors: steeringSettings.authors, - tonality: steeringSettings.tonalityEnabled - ? steeringSettings.tonality - : undefined, - temperature: steeringSettings.temperature, - numSuggestions: steeringSettings.numSuggestions, + console.log(`[Notes Debug] Saving reasoning with ID ${reasoningId} to DB`) + // Use Promise.all to save reasoning and notes concurrently + await Promise.all([ + // Ensure reasoningRecordToSave matches the schema expectations + db.insert(schema.reasonings).values(reasoningRecordToSave), + ...newNotes.map((note) => { + console.log(`[Notes Debug] Saving note ${note.id} to DB`) + return db.insert(schema.notes).values({ + id: note.id, + content: note.content, + color: note.color, + createdAt: note.createdAt, + accessedAt: new Date(), + dropped: note.dropped ?? false, + fileId: dbFile!.id, + vaultId: note.vaultId, + reasoningId: note.reasoningId!, + steering: note.steering || null, + embeddingStatus: "in_progress", + embeddingTaskId: null, + }) + }), + ]) + + console.log(`[Notes Debug] Saving ${newNotes.length} new notes to DB`) + console.log(`[Notes Debug] Notes and reasoning saved successfully.`) + + // **Update UI state AFTER successful DB operations** + // Fix: Ensure the object matches ReasoningHistory type + setReasoningHistory((prev) => [ + ...prev, + { + ...reasoningRecordToSave, + timestamp: now, + reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime }, - }) - - // Insert notes into database - for (const note of newNotes) { - await db.insert(schema.notes).values({ - id: note.id, - content: note.content, - color: note.color, - createdAt: note.createdAt, - accessedAt: new Date(), - dropped: note.dropped ?? false, - fileId: dbFile!.id, - vaultId: note.vaultId, - reasoningId: note.reasoningId!, - steering: note.steering || null, - embeddingStatus: "in_progress", - embeddingTaskId: null, - }) - } + ]) - // Add the newly created notes to our state setNotes((prev) => { const combined = [...newNotes, ...prev] return combined.sort( @@ -1595,8 +1715,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Set current generation notes for UI setCurrentGenerationNotes(newNotes) + console.log(`[Notes Debug] Processing embeddings for ${newNotes.length} new notes`) // Submit each note for embedding individually and track task IDs for polling for (const note of newNotes) { + console.log(`[Notes Debug] Submitting note ${note.id} for embedding`) const result = await submitNoteForEmbedding(db, note) // If successful and we have a task ID, add it for polling @@ -1611,12 +1733,25 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { if (updatedNote?.embeddingTaskId) { addTask(updatedNote.embeddingTaskId) + console.log( + `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, + ) + } else { + console.log( + `[Notes Debug] Note ${note.id} has no embedding task ID after submission`, + ) } + } else { + console.log(`[Notes Debug] Failed to submit note ${note.id} for embedding`) } } + + // Log the notes after saving to verify they were saved correctly + await logNotesForFile(db, dbFile!.id, vaultId, "After generating new suggestions") } catch (dbError) { console.error("Failed to save notes to database:", dbError) // Still show notes in UI even if DB fails + console.log(`[Notes Debug] Showing notes in UI despite DB save failure`) setCurrentGenerationNotes(newNotes) setNotes((prev) => { const combined = [...newNotes, ...prev] @@ -1627,8 +1762,18 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } } else { // For unsaved files, just display in UI without saving to DB + console.log(`[Notes Debug] Displaying ephemeral notes for unsaved file`) setShowEphemeralBanner(true) setTimeout(() => setShowEphemeralBanner(false), 7000) + // Update UI immediately for ephemeral notes + setReasoningHistory((prev) => [ + ...prev, + { + ...reasoningRecordToSave, + timestamp: now, + reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime + }, + ]) setCurrentGenerationNotes(newNotes) setNotes((prev) => { const combined = [...newNotes, ...prev] @@ -1642,14 +1787,18 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { error.message || "Notes not available for this generation, try again later" setNotesError(errorMessage) setCurrentlyGeneratingDateKey(null) - console.error("Failed to generate notes:", error) + console.error(`[Notes Debug] Failed to generate notes: ${errorMessage}`, error) } finally { setIsNotesLoading(false) } - } catch {} + } catch (error) { + console.error("[Notes Debug] Error in generateNewSuggestions:", error) + } }, [ - currentFile, + vaultId, + currentFileId, + currentFileHandle, fetchNewNotes, vault, streamingSuggestionColors, @@ -1739,7 +1888,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Find the file in the database to make sure we have the right reference const dbFile = await db.query.files.findFirst({ where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault?.id || "")), + and(eq(files.id, currentFileId!), eq(files.vaultId, vault?.id || "")), }) if (!dbFile) { @@ -1783,31 +1932,31 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { console.error("Failed to update note status:", error) } }, - [droppedNotes, db, currentFile, vault, setNotes], + [droppedNotes, db, vault, currentFileId, setNotes], ) - // Update handleFileSelect to use processAuthors const handleFileSelect = useCallback( async (node: FileSystemTreeNode) => { if (!vault || node.kind !== "file" || !codeMirrorViewRef.current || !node.handle) return try { - // Add a flag to track if processing has already started for this file - const fileKey = `${vaultId}:${node.name}` - if (loadedFiles.current.has(fileKey)) { - console.log( - `[Debug] File ${node.name} is already being processed, skipping duplicate processing`, - ) - return - } + console.log(`[Notes Debug] Selecting file: ${node.name} (${node.id})`) - // Mark file as being processed immediately to prevent race conditions - loadedFiles.current.add(fileKey) + // Reset all note states when selecting a new file + // This prevents stale notes from appearing temporarily + setNotes([]) + setDroppedNotes([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setReasoningHistory([]) + + // Reset embedding processing flags for new file selection + embeddingProcessedRef.current = null + essayEmbeddingProcessedRef.current = null - // Only do file I/O once const file = await node.handle!.getFile() const content = await file.text() - const fileName = file.name // If the node doesn't have a handleId, create one and store it let handleId = node.handleId @@ -1815,7 +1964,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { handleId = createId() // Store the handle for future use try { - await storeHandle(handleId, vaultId, handleId, node.handle as FileSystemFileHandle) + await storeHandle(handleId, vaultId, node.id, node.handle as FileSystemFileHandle) } catch (storeError) { console.error("Failed to store handle:", storeError) // Continue without storing - non-critical @@ -1825,121 +1974,103 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { try { const isValid = await verifyHandle(node.handle) if (!isValid) { - await storeHandle(handleId, vaultId, handleId, node.handle as FileSystemFileHandle) + await storeHandle(handleId, vaultId, node.id, node.handle as FileSystemFileHandle) } } catch (verifyError) { console.error("Failed to verify handle:", verifyError) } } - // Save the current file info to localStorage for this vault - if (vaultId) { - try { - localStorage.setItem( - `morph:last-file:${vaultId}`, - JSON.stringify({ - fileName, - lastAccessed: new Date().toISOString(), - handleId: handleId || node.id, // Use handleId if available, fall back to node.id - }), - ) - } catch (storageError) { - console.error("Failed to save file info to localStorage:", storageError) - // Non-critical, we can continue - } - } + // Set the fileId early so it can be used immediately + const targetFileId = node.id + setCurrentFileId(targetFileId) - // Load file content first - const success = loadFileContent(fileName, node.handle as FileSystemFileHandle, content) + // Load file content - this should now find the correct file ID + const success = await loadFileContent( + targetFileId, + node.handle as FileSystemFileHandle, + content, + ) if (success) { - try { - // Load file metadata - this will handle resetting and updating notes and reasonings - await loadFileMetadata(fileName) + console.log( + `[Notes Debug] Successfully loaded content for file ${targetFileId}, now loading metadata`, + ) - // Process essay embeddings for file that's not "Untitled" - if (fileName !== "Untitled") { - try { - // First check if we've already started processing - if (essayEmbeddingProcessedRef.current) { - console.log( - `[Debug] Essay embeddings for ${fileName} already being processed, skipping`, - ) - return - } + // Load file metadata using the now properly set currentFileId + await loadFileMetadata(targetFileId) - // Mark as processing immediately to prevent race conditions - essayEmbeddingProcessedRef.current = true + // Log notes for the file after metadata is loaded + if (vault) { + await logNotesForFile(db, targetFileId, vaultId, "After file selection") + } - // Find the file in the database to get its ID - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, fileName), eq(files.vaultId, vault.id)), - }) + console.log( + `[Notes Debug] Saving file info with ID: ${targetFileId}, handleId: ${handleId || node.id}`, + ) + // Save the last accessed file info with the correct ID + saveLastFileInfo(vaultId, targetFileId, handleId || node.id) + + // Update restored file in context for future restorations + setRestoredFile({ + file, + fileHandle: node.handle as FileSystemFileHandle, + fileId: targetFileId, + content, + handleId: handleId || node.id, + }) - if (dbFile) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) + // Process essay embeddings if needed + if (targetFileId !== null) { + try { + // Skip if already being processed + if (essayEmbeddingProcessedRef.current === `${vaultId}:${targetFileId}`) { + console.log( + `[Notes Debug] Skipping duplicate essay embedding processing for ${node.name}`, + ) + return + } - if (!hasEmbeddings) { - // Process essay embeddings asynchronously - console.log(`[Debug] Processing essay embeddings for ${fileName}`) - const cleaned = md(content).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: cleaned, - currentVaultId: vault.id, - currentFileId: dbFile.id, - }) - } else { - console.log( - `[Debug] File ${fileName} already has embeddings, skipping processing`, - ) - } + // Mark as processing immediately using ID + essayEmbeddingProcessedRef.current = `${vaultId}:${targetFileId}` - // Process any existing notes for this file only once - if (!embeddingProcessedRef.current) { - embeddingProcessedRef.current = true - processEmbeddings.mutate({ addTask }) - } - } - } catch (error) { - console.error("Error processing file data:", error) - // Reset flags on error to allow retrying - essayEmbeddingProcessedRef.current = false - embeddingProcessedRef.current = false + // Check if this file already has embeddings + const hasEmbeddings = await checkFileHasEmbeddings(db, targetFileId) + + if (!hasEmbeddings) { + console.log(`[Notes Debug] Processing essay embeddings for ${node.name}`) + const cleaned = md(content).content + processEssayEmbeddings.mutate({ + addTask: addEssayTask, + currentContent: cleaned, + currentVaultId: vaultId, + currentFileId: targetFileId, + }) + } else { + console.log( + `[Notes Debug] File ${node.name} already has embeddings, skipping processing`, + ) } - } - } catch (loadError) { - console.error("Error loading file metadata:", loadError) - // Reset the note states if metadata loading fails - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - // Remove from loaded files to allow retrying - loadedFiles.current.delete(fileKey) + // Process any existing notes for this file only once + if (!embeddingProcessedRef.current) { + console.log(`[Notes Debug] Processing note embeddings for ${node.name}`) + embeddingProcessedRef.current = `${vaultId}:${targetFileId}` + processEmbeddings.mutate({ addTask }) + } + } catch (error) { + console.error("Error processing file data:", error) + // Reset flags on error to allow retrying + essayEmbeddingProcessedRef.current = null + embeddingProcessedRef.current = null + } } } else { - // Remove from loaded files if loading fails - loadedFiles.current.delete(fileKey) - - toast({ - title: "Error Loading File", - description: `Could not load file ${node.name}. Please try again.`, - variant: "destructive", - }) + console.log(`[Notes Debug] Failed to load content for file ${targetFileId}`) } } catch (error) { console.error("Error handling file selection:", error) - // Make sure to remove the file from loaded files on error - const fileKey = `${vaultId}:${node.name}` - loadedFiles.current.delete(fileKey) - toast({ title: "Error Loading File", description: `Could not load file ${node.name}. Please try again.`, @@ -1959,308 +2090,265 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { addTask, processEmbeddings, processEssayEmbeddings, + setRestoredFile, ], ) - // Add this ref to track if we've processed embeddings for the current file - const embeddingProcessedRef = useRef(false) + // Add the handler function (around line 500, with other handler functions) + const handleVisibleContextNotesChange = useCallback((noteIds: string[]) => { + setVisibleContextNoteIds(noteIds) + }, []) - // Add an effect to process all notes in the editor for embeddings - useEffect(() => { - if (notes.length === 0) return + const onContentChange = useCallback( + async (value: string) => { + // Only update hasUnsavedChanges if it's not already set + // This prevents constant re-renders + if (!hasUnsavedChanges && value !== contentRef.current.content) { + setHasUnsavedChanges(true) + } - // Check and process notes for embeddings after a short delay to avoid performance issues - const timeoutId = setTimeout(async () => { - try { - // Only process if we haven't already for this file - if (embeddingProcessedRef.current) return - - // Process up to 3 notes that don't have embeddings yet - const notesToProcess = [] - let processedCount = 0 - - // Create a stable copy of the notes array to avoid race conditions - const currentNotes = [...notes] - - for (const note of currentNotes) { - if (processedCount >= 3) break // Limit to 3 per batch - - // Only process notes that aren't already in progress or success - if ( - note.embeddingStatus !== "success" && - note.embeddingStatus !== "in_progress" && - !note.dropped - ) { - const hasEmbedding = await checkNoteHasEmbedding(db, note.id) - if (!hasEmbedding) { - notesToProcess.push(note) - processedCount++ - } - } - } + // Always update the current content + setMarkdownContent(value) - // Process collected notes one by one - for (const note of notesToProcess) { - const result = await submitNoteForEmbedding(db, note) + // Update preview with the new content using the debounced function + debouncedUpdatePreview(value) + }, + [debouncedUpdatePreview, hasUnsavedChanges, contentRef], // Use debounced function here + ) - // If successful and we have a task ID, add it for polling - if (result) { - const updatedNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) + // Effect to use preloaded file from context if available + useEffect(() => { + if (!restoredFile || fileRestorationAttempted.current) return - if (updatedNote?.embeddingTaskId) { - addTask(updatedNote.embeddingTaskId) - } - } - } + // Mark file as restored so we don't try again + fileRestorationAttempted.current = true - // Mark as processed after we're done - embeddingProcessedRef.current = true - } catch (error) { - console.error("[Embedding] Failed to check and process notes for embeddings:", error) - } - }, 2000) // 2 second delay + // Update local state even before CodeMirror is ready + // These happen before async operations and are safe + setMarkdownContent(restoredFile.content) + setCurrentFileId(restoredFile.fileId) + setCurrentFileHandle(restoredFile.fileHandle) + setHasUnsavedChanges(false) - return () => clearTimeout(timeoutId) - }, [currentFile, db]) // Only depend on stable values to avoid frequent re-renders + // Update preview immediately with the restored content + debouncedUpdatePreview(restoredFile.content) - // Add an effect to process file embeddings on mount and file change - useEffect(() => { - if (currentFile === "Untitled") return + // Create flags to track processing status + let embeddingProcessingStarted = false - // Process only if we haven't already for this file - if (essayEmbeddingProcessedRef.current) return + // If CodeMirror is ready, also update it + if (codeMirrorViewRef.current) { + // This will also set currentFileId via the loadFileContent function + loadFileContent(restoredFile.fileId, restoredFile.fileHandle, restoredFile.content).then( + async (success) => { + // Load file metadata if content loading was successful and we have a valid file ID + // Use the currentFileId set by loadFileContent + if (success && vault && currentFileId) { + try { + // Reset note states *before* loading new metadata + console.log( + `[Notes Debug] Resetting notes state before loading metadata for ${currentFileId}`, + ) + setNotes([]) + setDroppedNotes([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + setReasoningHistory([]) + // Reset embedding processing flags + embeddingProcessedRef.current = null + essayEmbeddingProcessedRef.current = null + + console.log(`[Notes Debug] Loading metadata for restored file: ${currentFileId}`) + await loadFileMetadata(currentFileId) + console.log(`[Notes Debug] Metadata loaded for ${currentFileId}`) + + // Log notes after metadata loading attempt + await logNotesForFile(db, currentFileId, vaultId, "After restoring file") + + // Process essay embeddings for restored file (only if online) + // Use a small timeout to let the file load completely first + embeddingTimeoutRef.current = setTimeout(async () => { + try { + // Skip if processing has already started or if file is already processed + if ( + embeddingProcessingStarted || + essayEmbeddingProcessedRef.current === `${vaultId}:${currentFileId}` + ) { + console.log( + `[Debug] Skipping duplicate essay embedding processing for restored file ${currentFileId}`, + ) + return + } - // Use a timeout to avoid processing immediately on mount - const timeoutId = setTimeout(async () => { - try { - // First check if we've already processed this file (double-check) - if (essayEmbeddingProcessedRef.current) return + // Mark as processing immediately + embeddingProcessingStarted = true + essayEmbeddingProcessedRef.current = `${vaultId}:${currentFileId}` - // Mark as processed right away to prevent race conditions - essayEmbeddingProcessedRef.current = true + // Check if this file already has embeddings - use the current file ID + const hasEmbeddings = await checkFileHasEmbeddings(db, currentFileId!) - // Find the file in the database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault?.id || "")), - }) + if (!hasEmbeddings) { + console.log(`[Debug] Processing embeddings for restored file ${currentFileId}`) + // Process essay embeddings asynchronously using the md content function + const content = md(restoredFile.content).content + processEssayEmbeddings.mutate({ + addTask: addEssayTask, + currentContent: content, + currentVaultId: vaultId, + currentFileId: currentFileId!, + }) + } else { + console.log(`[Debug] Restored file ${currentFileId} already has embeddings`) + } - if (dbFile && markdownContent) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) - - if (!hasEmbeddings) { - // Process essay embeddings asynchronously - const content = md(markdownContent).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: content, - currentVaultId: vault?.id || "", - currentFileId: dbFile.id, - }) + // Process any existing notes for this file (only once) + if (!embeddingProcessedRef.current) { + embeddingProcessedRef.current = `${vaultId}:${currentFileId}` + processEmbeddings.mutate({ addTask }) + } + } catch (error) { + console.error(`Error processing file data for ${currentFileId}:`, error) + // Reset flags on error to allow retrying + essayEmbeddingProcessedRef.current = null + embeddingProcessedRef.current = null + } + }, 1000) // 1 second delay to ensure DB operations are ready + } catch (error) { + console.error(`Error loading file metadata for ${currentFileId}:`, error) + // If metadata loading fails, ensure the notes state remains cleared + setNotes([]) + setDroppedNotes([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError( + `Failed to load notes: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + setReasoningHistory([]) + } } - } - } catch (error) { - console.error("[EssayEmbedding] Failed to process file embeddings:", error) - // Reset the flag on error so we can try again - essayEmbeddingProcessedRef.current = false - } - }, 3000) // 3 second delay - - return () => clearTimeout(timeoutId) - }, [currentFile, db, markdownContent, processEssayEmbeddings, addEssayTask]) + }, + ) + } - // Use the embedding status hook - // This hooks has some side-effects, and must be called at the very last. - const [embeddingStatus, setEmbeddingStatus] = useState(null) + // Cleanup timeout on unmount or if dependencies change + return () => { + if (embeddingTimeoutRef.current) { + clearTimeout(embeddingTimeoutRef.current) + } + } + }, [ + vaultId, + restoredFile, + loadFileContent, + loadFileMetadata, + vault, + db, + processEmbeddings, + processEssayEmbeddings, + addTask, + addEssayTask, + debouncedUpdatePreview, + currentFileId, + ]) + // Update the ref when dependencies change, but don't recreate the event listener useEffect(() => { - // If there are pending tasks (either notes or essays), show in-progress status - if (pendingTaskIds.length > 0 || essayPendingTaskIds.length > 0) { - setEmbeddingStatus("in_progress") - return - } + keyHandlerRef.current = (event: KeyboardEvent) => { + // Special handling for settings shortcut - high priority + if ((event.metaKey || event.ctrlKey) && event.key === ",") { + event.preventDefault() + event.stopPropagation() // Stop event from bubbling up + toggleSettings() + return + } - // If no active tasks, but we need to see if notes have embeddings - const allNotes = [...notes, ...currentGenerationNotes, ...droppedNotes].filter(Boolean) + // Save shortcut should have priority + if ((event.ctrlKey || event.metaKey) && event.key === "s") { + event.preventDefault() + event.stopPropagation() + handleSave() + return + } - if (allNotes.length === 0) { - setEmbeddingStatus(null) - return + // Other keyboard shortcuts + if (event.key === settings.toggleNotes && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleNotes() + } else if (event.key === settings.toggleEditMode && (event.metaKey || event.altKey)) { + event.preventDefault() + setIsEditMode((prev) => !prev) + } } + }, [handleSave, toggleNotes, settings.toggleNotes, settings.toggleEditMode, toggleSettings]) - // Check if any notes still show in-progress (but might not have active tasks) - const anyInProgress = allNotes.some((note) => note.embeddingStatus === "in_progress") - if (anyInProgress) { - setEmbeddingStatus("in_progress") - return + // Effect to update vimMode state when settings change + useEffect(() => { + // Only update vimMode if it differs from settings.vimMode + if (settings.vimMode !== vimMode) { + console.log("Updating vim mode from settings") + setVimMode(settings.vimMode ?? false) } + }, [settings.vimMode, vimMode]) - // Check if all notes have successful embeddings - const allSuccess = allNotes.every((note) => note.embeddingStatus === "success") - if (allSuccess && allNotes.length > 0) { - setEmbeddingStatus("success") - return + // Register vim commands separately to avoid type errors + useEffect(() => { + if (vimMode) { + // Register our vim commands for saving + Vim.defineEx("w", "w", () => { + handleSave() + }) + Vim.defineEx("wa", "wa", () => { + handleSave() + }) + // Register other vim commands + Vim.map(";", ":", "normal") + Vim.map("jj", "", "insert") + Vim.map("jk", "", "insert") } - - // Check if any notes failed - const anyFailed = allNotes.some( - (note) => note.embeddingStatus === "failure" || note.embeddingStatus === "cancelled", - ) - if (anyFailed) { - setEmbeddingStatus("failure") - return + return () => { + // Clean up vim commands when dependencies change + Vim.unmap(";", "normal") + Vim.unmap("jj", "insert") + Vim.unmap("jk", "insert") } + }, [vimMode, handleSave]) - // Default to null if we can't determine status - setEmbeddingStatus(null) - }, [notes, currentGenerationNotes, droppedNotes, pendingTaskIds, essayPendingTaskIds]) - - // Add a ref to track if we've processed essay embeddings for the current file - const essayEmbeddingProcessedRef = useRef(false) - - // Reset the essay embedding processed flag when the file changes + // Stable keyboard handler event listener setup useEffect(() => { - essayEmbeddingProcessedRef.current = false - }, [currentFile]) - - // Add an effect to process file embeddings on mount and file change - useEffect(() => { - if (currentFile === "Untitled") return - - // Process only if we haven't already for this file - if (essayEmbeddingProcessedRef.current) return - - // Use a timeout to avoid processing immediately on mount - const timeoutId = setTimeout(async () => { - try { - // First check if we've already processed this file (double-check) - if (essayEmbeddingProcessedRef.current) return - - // Mark as processed right away to prevent race conditions - essayEmbeddingProcessedRef.current = true - - // Find the file in the database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault?.id || "")), - }) + // Create a stable handler that uses the current ref value + const stableHandler = (e: KeyboardEvent) => { + keyHandlerRef.current?.(e) + } - if (dbFile && markdownContent) { - // Check if this file already has embeddings - const hasEmbeddings = await checkFileHasEmbeddings(db, dbFile.id) - - if (!hasEmbeddings) { - // Process essay embeddings asynchronously - const content = md(markdownContent).content - processEssayEmbeddings.mutate({ - addTask: addEssayTask, - currentContent: content, - currentVaultId: vault?.id || "", - currentFileId: dbFile.id, - }) - } - } - } catch (error) { - console.error("[EssayEmbedding] Failed to process file embeddings:", error) - // Reset the flag on error so we can try again - essayEmbeddingProcessedRef.current = false - } - }, 3000) // 3 second delay + // Add event listener only once + console.log("Registering keyboard handler") + window.addEventListener("keydown", stableHandler, { capture: true }) - return () => clearTimeout(timeoutId) - }, [currentFile, db, markdownContent, processEssayEmbeddings, addEssayTask]) + // Clean up on unmount only + return () => { + console.log("Removing keyboard handler") + window.removeEventListener("keydown", stableHandler, { capture: true }) + } + }, []) // Empty dependency array = run once on mount // Add an effect to update CodeMirror when vim mode changes useEffect(() => { // Only run if CodeMirror view is available if (codeMirrorViewRef.current) { - // Create a new state that either includes or excludes vim() based on vimMode + console.log("Updating CodeMirror vim mode") + + // Instead of trying to query facets directly, just create a new state when mode changes const newState = EditorState.create({ doc: codeMirrorViewRef.current.state.doc, selection: codeMirrorViewRef.current.state.selection, - extensions: [ - ...memoizedExtensions.filter((ext) => ext !== vim()), - ...(vimMode ? [vim()] : []), - ], + extensions: vimMode ? [...baseExtensions, vim()] : baseExtensions, }) // Update the view with the new state codeMirrorViewRef.current.setState(newState) } - }, [vimMode, memoizedExtensions]) - - // Effect to check for vim mode change notification in localStorage - useEffect(() => { - // Check if there's a vim mode change notification in localStorage - const vimModeChanged = localStorage.getItem("morph:vim-mode-changed") === "true" - - if (vimModeChanged) { - // Show the notification - setShowVimModeChangeNotification(true) - - // Clear the notification flag from localStorage - localStorage.removeItem("morph:vim-mode-changed") - - // Set a timeout to dismiss the notification after a few seconds - const timer = setTimeout(() => { - setShowVimModeChangeNotification(false) - }, 5000) - - return () => clearTimeout(timer) - } - }, []) - - // Add a ref to track the current file name to avoid unnecessary database queries - // Add near other ref declarations (around line 150) - const currentFileRef = useRef("") - - useEffect(() => { - // Only attempt to find the file ID if we have a valid vault and a non-default file name - if (vault && currentFile && currentFile !== "Untitled") { - // Skip if we've already processed this file - if (currentFile === currentFileRef.current && currentFileId) { - return - } - - const caller = async () => { - try { - // Find the current file in the database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.name, currentFile), eq(files.vaultId, vault.id)), - }) - - // Update the state with the file ID if found - if (dbFile) { - setCurrentFileId(dbFile.id) - currentFileRef.current = currentFile - } else { - setCurrentFileId(null) - currentFileRef.current = "" - } - } catch (error) { - console.error(`[ContextNotes] Error finding file ID for ${currentFile}:`, error) - setCurrentFileId(null) - currentFileRef.current = "" - } - } - const timeout = setTimeout(caller, 1000) - - return () => clearTimeout(timeout) - } else { - // Reset the file ID if we don't have a valid file - setCurrentFileId(null) - currentFileRef.current = "" - } - }, [db, vault, currentFile, currentFileId]) - - // Add the handler function (around line 500, with other handler functions) - const handleVisibleContextNotesChange = useCallback((noteIds: string[]) => { - setVisibleContextNoteIds(noteIds) - }, []) + }, [vimMode, baseExtensions]) // Only depend on vimMode and baseExtensions return ( @@ -2277,9 +2365,9 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setIsSettingsOpen={toggleSettings} /> - - - +
+ + {showEphemeralBanner && ( @@ -2350,76 +2438,42 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { )} - {/* Add vim mode change notification banner */} - - {showVimModeChangeNotification && ( - - - ⚠️ Turning{" "} - {localStorage.getItem("morph:vim-mode-value") === "true" ? "on" : "off"} Vim - mode requires a page refresh to take effect - - - - )} - - - - {memoizedDroppedNotes.length > 0 && ( - - )} - +
{hasUnsavedChanges && } - {embeddingStatus && }
- {vault && currentFileId && memoizedDroppedNotes.length > 0 && !isNotesLoading && ( - - )} + {vault && + currentFileId !== null && + memoizedDroppedNotes.length > 0 && + !isNotesLoading && ( + + )}
@@ -2429,13 +2483,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { height="100%" autoFocus placeholder={"What's on your mind?"} - basicSetup={{ - rectangularSelection: false, - indentOnInput: true, - syntaxHighlighting: true, - highlightActiveLine: true, - highlightSelectionMatches: true, - }} + basicSetup={memoizedBasicSetup} // Use memoized object indentWithTab={true} extensions={memoizedExtensions} onChange={onContentChange} @@ -2458,35 +2506,40 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) {
- {showNotes && ( -
- + {showNotes && currentFileId !== null && ( +
+ + + +
)} - + +}) { // Use our context hook instead of local state const { pendingTaskIds, removeTask } = useEssayEmbeddingTasks() @@ -18,30 +25,29 @@ export const EssayEmbeddingProcessor = memo(function EssayEmbeddingProcessor() { }, [removeTask], ) - // Only create task pollers if there are pending tasks to process // This prevents unnecessary polling when there are no essays to process - const taskPollers = useMemo(() => { - if (pendingTaskIds.length === 0) { - return null // Return null if there are no pending tasks - } + const TaskPollers = useCallback(() => { + if (pendingTaskIds.length === 0) return null return pendingTaskIds.map((taskId) => ( handleTaskComplete(taskId)} /> )) - }, [pendingTaskIds, handleTaskComplete]) + }, [pendingTaskIds, handleTaskComplete, db]) // Return the task pollers only if there are tasks to poll - return <>{taskPollers} + return }) interface EssayEmbeddingTaskPollerProps { taskId: string onComplete: () => void + db: PgliteDatabase } /** @@ -50,9 +56,10 @@ interface EssayEmbeddingTaskPollerProps { const EssayEmbeddingTaskPoller = memo(function EssayEmbeddingTaskPoller({ taskId, onComplete, + db, }: EssayEmbeddingTaskPollerProps) { // Use the hook to poll this task, but only if we have a valid taskId - const { data, isSuccess, isError } = useQueryEssayEmbeddingStatus(taskId) + const { data, isSuccess, isError } = useQueryEssayEmbeddingStatus(taskId, db) // When task is successful or fails, call the onComplete callback useEffect(() => { diff --git a/packages/morph/components/formatted-date.tsx b/packages/morph/components/formatted-date.tsx index e025500..c7e6969 100644 --- a/packages/morph/components/formatted-date.tsx +++ b/packages/morph/components/formatted-date.tsx @@ -1,6 +1,6 @@ -import * as React from "react" import { cn } from "@/lib" import { formatDateString } from "@/lib" +import * as React from "react" interface FormattedDateProps { dateString: string diff --git a/packages/morph/components/landing/page-transition.tsx b/packages/morph/components/landing/page-transition.tsx deleted file mode 100644 index 4853e40..0000000 --- a/packages/morph/components/landing/page-transition.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { useRouter } from "next/navigation" -import { useEffect, useRef } from "react" - -export function PageTransition({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { - const router = useRouter() - const transitionRef = useRef(null) - - // Effect for page transitions - useEffect(() => { - // Add event listener to Links with href="/vaults" - const handleLinkClick = (e: Event) => { - e.preventDefault() - - // Start the transition animation - if (transitionRef.current) { - transitionRef.current.classList.add("active") - - // Navigate after animation completes - setTimeout(() => { - router.push("/vaults") - }, 600) // Slightly shorter than animation duration - } - } - - // Find all vault links - const vaultLinks = document.querySelectorAll('a[href="/vaults"]') - vaultLinks.forEach((link) => { - link.addEventListener("click", handleLinkClick) - }) - - return () => { - vaultLinks.forEach((link) => { - link.removeEventListener("click", handleLinkClick) - }) - } - }, [router]) - - return ( - <> - {children} -
- - - ) -} diff --git a/packages/morph/components/markdown-inline.ts b/packages/morph/components/markdown-inline.ts index 55e98b8..18b254c 100644 --- a/packages/morph/components/markdown-inline.ts +++ b/packages/morph/components/markdown-inline.ts @@ -21,16 +21,7 @@ import { htmlPlugins, markdownPlugins } from "./parser" export type HtmlContent = [HtmlRoot, VFile, string] -function shouldProcessFile(filename: string): boolean { - const processableExtensions = [".md", ".mdx", ".markdown", ".txt"] - return processableExtensions.some((ext) => filename.toLowerCase().endsWith(ext)) -} - -function processor(filename: string, settings: Settings, vaultId: string) { - if (filename.endsWith(".mdx")) { - return unified() - } - +function processor(settings: Settings, vaultId: string) { return unified() .use(remarkParse) .use(markdownPlugins(settings, vaultId)) @@ -41,13 +32,11 @@ function processor(filename: string, settings: Settings, vaultId: string) { let mdProcessor: ReturnType | null = null -const cached = new Map() - interface ConverterOptions { value: string vaultId: string settings: Settings - filename: string + fileid: string | null returnHast?: boolean } @@ -56,40 +45,27 @@ export async function mdToHtml(opts: ConverterOptions): Promise export async function mdToHtml({ value, vaultId, - filename, + fileid, settings, returnHast, }: ConverterOptions): Promise { returnHast = returnHast ?? false if (!value.trim()) return returnHast ? { type: "root", children: [] } : "" - if (filename && !shouldProcessFile(filename)) - return returnHast - ? { - type: "root", - children: [{ type: "text", value }], - } - : value - value = value .replace(/^ +/, (spaces) => spaces.replace(/ /g, "\u00A0")) .toString() .trim() - const cacheKey = `${filename || "local"}:${value}` - const cachedResult = cached.get(cacheKey) - if (cachedResult) return returnHast ? cachedResult[0] : String(cachedResult[2]) - const file = new VFile() file.value = value - if (filename) file.path = filename + file.path = fileid || "" try { - if (!mdProcessor) mdProcessor = processor(filename, settings, vaultId) + if (!mdProcessor) mdProcessor = processor(settings, vaultId) const ast = mdProcessor.parse(file) as MdRoot const newAst = (await mdProcessor.run(ast, file)) as HtmlRoot const result = mdProcessor.stringify(newAst, file) // save ast for parsing reading mode - cached.set(cacheKey, [newAst, file, result.toString()]) return returnHast ? newAst : result.toString() } catch (error) { console.error("Error rendering content:", error) @@ -102,10 +78,10 @@ export async function mdToHtml({ } } -export const setFile = StateEffect.define() +export const setFile = StateEffect.define() -export const fileField = StateField.define({ - create: () => "", +export const fileField = StateField.define({ + create: () => null, update: (value, tr) => { for (const effect of tr.effects) { if (effect.is(setFile)) return effect.value diff --git a/packages/morph/components/note-embedding-processor.tsx b/packages/morph/components/note-embedding-processor.tsx index f79b12b..44ef1b8 100644 --- a/packages/morph/components/note-embedding-processor.tsx +++ b/packages/morph/components/note-embedding-processor.tsx @@ -1,13 +1,20 @@ import { useQueryNoteEmbeddingStatus } from "@/services/notes" -import { memo, useCallback, useEffect, useMemo } from "react" +import { PgliteDatabase } from "drizzle-orm/pglite" +import { memo, useCallback, useEffect } from "react" import { useEmbeddingTasks } from "@/context/embedding" +import * as schema from "@/db/schema" + /** * Component that manages embedding tasks polling. * This is a "headless" component that doesn't render anything. */ -export const NoteEmbeddingProcessor = memo(function NoteEmbeddingProcessor() { +export const NoteEmbeddingProcessor = memo(function NoteEmbeddingProcessor({ + db, +}: { + db: PgliteDatabase +}) { // Use our context hook instead of local state const { pendingTaskIds, removeTask } = useEmbeddingTasks() @@ -21,27 +28,27 @@ export const NoteEmbeddingProcessor = memo(function NoteEmbeddingProcessor() { // Only create task pollers if there are pending tasks to process // This prevents unnecessary polling when there are no notes to process - const taskPollers = useMemo(() => { - if (pendingTaskIds.length === 0) { - return null // Return null if there are no pending tasks - } + const TaskPollers = useCallback(() => { + if (pendingTaskIds.length === 0) return null return pendingTaskIds.map((taskId) => ( handleTaskComplete(taskId)} /> )) - }, [pendingTaskIds, handleTaskComplete]) + }, [pendingTaskIds, handleTaskComplete, db]) // Return the task pollers only if there are tasks to poll - return <>{taskPollers} + return }) interface NoteEmbeddingTaskPollerProps { taskId: string onComplete: () => void + db: PgliteDatabase } /** @@ -50,9 +57,10 @@ interface NoteEmbeddingTaskPollerProps { const NoteEmbeddingTaskPoller = memo(function NoteEmbeddingTaskPoller({ taskId, onComplete, + db, }: NoteEmbeddingTaskPollerProps) { // Use the hook to poll this task, but only if we have a valid taskId - const { data, isSuccess, isError } = useQueryNoteEmbeddingStatus(taskId) + const { data, isSuccess, isError } = useQueryNoteEmbeddingStatus(taskId, db) // When task is successful or fails, call the onComplete callback useEffect(() => { diff --git a/packages/morph/components/note-group.tsx b/packages/morph/components/note-group.tsx index 6fcbad5..d141a9f 100644 --- a/packages/morph/components/note-group.tsx +++ b/packages/morph/components/note-group.tsx @@ -7,9 +7,9 @@ import { drizzle } from "drizzle-orm/pglite" import { AnimatePresence, motion, useMotionValue, useTransform } from "motion/react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { FormattedDate } from "@/components/formatted-date" import { AttachedNoteCard, DraggableNoteCard } from "@/components/note-card" import { ReasoningPanel } from "@/components/reasoning-panel" -import { FormattedDate } from "@/components/formatted-date" import { usePGlite } from "@/context/db" @@ -36,7 +36,7 @@ interface NoteGroupProps { content: string reasoningElapsedTime: number } - currentFile: string + fileId: string vaultId?: string handleNoteDropped: (note: Note) => void onNoteRemoved: (noteId: string) => void @@ -48,7 +48,6 @@ export const NoteGroup = memo( dateStr, dateNotes, reasoning, - currentFile, vaultId, handleNoteDropped, onNoteRemoved, @@ -122,7 +121,6 @@ export const NoteGroup = memo( reasoning={reasoning.content} isStreaming={false} isComplete={true} - currentFile={currentFile} vaultId={vaultId} reasoningId={reasoning.id} shouldExpand={isExpanded} @@ -130,7 +128,7 @@ export const NoteGroup = memo( onExpandChange={setIsExpanded} /> ) - }, [reasoning, currentFile, vaultId, isExpanded]) + }, [reasoning, vaultId, isExpanded]) return (
@@ -165,7 +163,7 @@ export const NoteGroup = memo( prevProps.dateStr === nextProps.dateStr && prevProps.dateNotes === nextProps.dateNotes && prevProps.reasoning === nextProps.reasoning && - prevProps.currentFile === nextProps.currentFile && + prevProps.fileId === nextProps.fileId && prevProps.isGenerating === nextProps.isGenerating ) }, @@ -198,11 +196,14 @@ export const DroppedNoteGroup = memo( const hasNotes = droppedNotes.length > 0 // Use empty array if visibleContextNoteIds is undefined - const safeVisibleContextNoteIds = useMemo(() => visibleContextNoteIds || [], [visibleContextNoteIds]) + const safeVisibleContextNoteIds = useMemo( + () => visibleContextNoteIds || [], + [visibleContextNoteIds], + ) // Filter out notes that are visible in context view const filteredNotes = useMemo(() => { - return droppedNotes.filter(note => !safeVisibleContextNoteIds.includes(note.id)) + return droppedNotes.filter((note) => !safeVisibleContextNoteIds.includes(note.id)) }, [droppedNotes, safeVisibleContextNoteIds]) // Get the actual notes to display (either filtered or all) @@ -213,7 +214,7 @@ export const DroppedNoteGroup = memo( // Keep track of notes currently in context view const inContextNotes = useMemo(() => { - return droppedNotes.filter(note => safeVisibleContextNoteIds.includes(note.id)) + return droppedNotes.filter((note) => safeVisibleContextNoteIds.includes(note.id)) }, [droppedNotes, safeVisibleContextNoteIds]) // Motion values for chevron drag @@ -446,7 +447,7 @@ export const DroppedNoteGroup = memo( return false // Re-render if length of visibleContextNoteIds changed } - if (!prevVisibleIds.every(id => nextVisibleIds.includes(id))) { + if (!prevVisibleIds.every((id) => nextVisibleIds.includes(id))) { return false // Re-render if contents of visibleContextNoteIds changed } diff --git a/packages/morph/components/note-panel.tsx b/packages/morph/components/note-panel.tsx index 5b1b34a..23460f2 100644 --- a/packages/morph/components/note-panel.tsx +++ b/packages/morph/components/note-panel.tsx @@ -18,6 +18,7 @@ import { } from "@/components/steering-panel" import { VaultButton } from "@/components/ui/button" +import { useLoading } from "@/context/loading" import { SteeringSettings, useSteeringContext } from "@/context/steering" import type { Note } from "@/db/interfaces" @@ -31,7 +32,7 @@ interface NotesPanelProps { droppedNotes: Note[] streamingReasoning: string reasoningComplete: boolean - currentFile: string + fileId: string vaultId?: string currentReasoningId: string reasoningHistory: { @@ -44,7 +45,6 @@ interface NotesPanelProps { handleNoteDropped: (note: Note) => void handleNoteRemoved: (noteId: string) => void handleCurrentGenerationNote: (note: Note) => void - isNotesRecentlyGenerated: boolean currentReasoningElapsedTime: number generateNewSuggestions: (steeringSettings: SteeringSettings) => void noteGroupsData: [string, Note[]][] @@ -88,159 +88,162 @@ const ScrollSeekPlaceholder: Components["ScrollSeekPlaceholder"] = memo( // Create a memoized driver bar component for the notes panel interface DriversBarProps { handleGenerateNewSuggestions: (steeringSettings: SteeringSettings) => void - isNotesLoading: boolean - isNotesRecentlyGenerated: boolean } -const DriversBar = memo( - function DriversBar({ - handleGenerateNewSuggestions, - isNotesLoading, - isNotesRecentlyGenerated, - }: DriversBarProps) { - const [isSteeringExpanded, setIsSteeringExpanded] = useState(false) - const { - settings, - updateAuthors, - updateTonality, - updateTemperature, - updateNumSuggestions, - toggleTonality, - } = useSteeringContext() - - const toggleSteeringPanel = useCallback(() => { - setIsSteeringExpanded((prev) => !prev) - }, []) +const DriversBarButtons = memo(function DriversBarButtons({ + onToggleSteeringPanel, + onGenerateNewSuggestions, +}: { + onToggleSteeringPanel: () => void + onGenerateNewSuggestions: () => void +}) { + const { state } = useLoading() + const { isNotesLoading, isNotesRecentlyGenerated } = state - // Close steering panel when the notes panel is closed - useEffect(() => { - return () => { - // This cleanup function will run when the component unmounts - // (which happens when the notes panel is closed) - if (isSteeringExpanded) { - setIsSteeringExpanded(false) - } + return ( +
+ + + + + + +
+ ) +}) + +export const DriversBar = memo(function DriversBar({ + handleGenerateNewSuggestions, +}: DriversBarProps) { + const [isSteeringExpanded, setIsSteeringExpanded] = useState(false) + const { + settings, + updateAuthors, + updateTonality, + updateTemperature, + updateNumSuggestions, + toggleTonality, + } = useSteeringContext() + + const toggleSteeringPanel = useCallback(() => { + setIsSteeringExpanded((prev) => !prev) + }, []) + + // Close steering panel when the notes panel is closed + useEffect(() => { + return () => { + // This cleanup function will run when the component unmounts + // (which happens when the notes panel is closed) + if (isSteeringExpanded) { + setIsSteeringExpanded(false) } - }, [isSteeringExpanded]) + } + }, [isSteeringExpanded]) - // Handler functions for steering controls - const handleUpdateAuthors = useCallback( - (authors: string[]) => { - updateAuthors(authors) - }, - [updateAuthors], - ) + // Handler functions for steering controls + const handleUpdateAuthors = useCallback( + (authors: string[]) => { + updateAuthors(authors) + }, + [updateAuthors], + ) - const handleUpdateTonality = useCallback( - (tonality: Record) => { - updateTonality(tonality) - }, - [updateTonality], - ) + const handleUpdateTonality = useCallback( + (tonality: Record) => { + updateTonality(tonality) + }, + [updateTonality], + ) - const handleUpdateTemperature = useCallback( - (temperature: number) => { - updateTemperature(temperature) - }, - [updateTemperature], - ) + const handleUpdateTemperature = useCallback( + (temperature: number) => { + updateTemperature(temperature) + }, + [updateTemperature], + ) - const handleUpdateNumSuggestions = useCallback( - (numSuggestions: number) => { - updateNumSuggestions(numSuggestions) - }, - [updateNumSuggestions], - ) + const handleUpdateNumSuggestions = useCallback( + (numSuggestions: number) => { + updateNumSuggestions(numSuggestions) + }, + [updateNumSuggestions], + ) - const handleToggleTonality = useCallback( - (enabled: boolean) => { - toggleTonality(enabled) - }, - [toggleTonality], - ) + const handleToggleTonality = useCallback( + (enabled: boolean) => { + toggleTonality(enabled) + }, + [toggleTonality], + ) - return ( -
- {isSteeringExpanded && ( -
-
-

Interpreter

- + const handleGenerate = useCallback(() => { + handleGenerateNewSuggestions(settings) + }, [handleGenerateNewSuggestions, settings]) + + return ( +
+ {isSteeringExpanded && ( +
+
+

Interpreter

+ +
+ +
+
+
-
-
- -
- -
- -
- -
- -
- -
- -
+
+ +
+ +
+ +
+ +
+
- )} -
- - - - - handleGenerateNewSuggestions(settings)} - disabled={isNotesLoading} - color="none" - size="small" - className={cn( - "text-primary border border-accent-foreground/40", - !isNotesRecentlyGenerated && "button-shimmer-border", - isNotesLoading && "cursor-not-allowed opacity-50 hover:cursor-not-allowed", - )} - title="Generate Suggestions" - > - -
-
- ) - }, - // Include all props in equality check - (prevProps, nextProps) => - prevProps.isNotesLoading === nextProps.isNotesLoading && - prevProps.isNotesRecentlyGenerated === nextProps.isNotesRecentlyGenerated && - prevProps.handleGenerateNewSuggestions === nextProps.handleGenerateNewSuggestions, -) + )} + +
+ ) +}) export const NotesPanel = memo(function NotesPanel({ notes, @@ -251,14 +254,13 @@ export const NotesPanel = memo(function NotesPanel({ droppedNotes, streamingReasoning, reasoningComplete, - currentFile, vaultId, + fileId, currentReasoningId, reasoningHistory, handleNoteDropped, handleNoteRemoved, handleCurrentGenerationNote, - isNotesRecentlyGenerated, currentReasoningElapsedTime, generateNewSuggestions, noteGroupsData, @@ -273,7 +275,7 @@ export const NotesPanel = memo(function NotesPanel({ // Track settings changes with a ref to detect actual value changes const prevSettingsRef = useRef(settings) - const currentFileRef = useRef(null) + const currentFileIdRef = useRef(null) // Add effect to properly track settings changes useEffect(() => { @@ -285,18 +287,18 @@ export const NotesPanel = memo(function NotesPanel({ // Add effect to update fileId in steering context when it changes useEffect(() => { // Skip for non-persisted files - if (currentFile === "Untitled") { + if (!fileId) { updateFileId(null) - currentFileRef.current = null + currentFileIdRef.current = null return } // Find the file ID from notes array - if (notes.length > 0 && notes[0].fileId && currentFileRef.current !== notes[0].fileId) { - currentFileRef.current = notes[0].fileId + if (notes.length > 0 && notes[0].fileId && currentFileIdRef.current !== notes[0].fileId) { + currentFileIdRef.current = notes[0].fileId updateFileId(notes[0].fileId) } - }, [currentFile, notes, updateFileId]) + }, [notes, updateFileId, fileId]) const [{ isOver }, drop] = useDrop( () => ({ @@ -341,7 +343,7 @@ export const NotesPanel = memo(function NotesPanel({ dateStr={dateStr} dateNotes={dateNotes} reasoning={dateReasoning} - currentFile={currentFile} + fileId={fileId} vaultId={vaultId} handleNoteDropped={handleNoteDropped} onNoteRemoved={handleNoteRemoved} @@ -351,8 +353,8 @@ export const NotesPanel = memo(function NotesPanel({ ) }, [ + fileId, memoizedNoteSkeletons, - currentFile, vaultId, handleNoteDropped, handleNoteRemoved, @@ -360,6 +362,30 @@ export const NotesPanel = memo(function NotesPanel({ ], ) + const memoizedVirtuoso = useMemo( + () => ( +
+ Math.abs(velocity) > 1000, + exit: (velocity) => Math.abs(velocity) < 100, + }} + customScrollParent={notesContainerRef.current!} + /> +
+ ), + [fileId, noteGroupsData, itemContent, notesContainerRef], + ) + return (
{!isNotesLoading && notes.length === 0 ? ( - +
+

+ {droppedNotes.length !== 0 + ? "All notes are currently in the stack." + : "No notes found for this document"} +

+
) : (
0} @@ -442,7 +455,7 @@ export const NotesPanel = memo(function NotesPanel({ id: note.id, content: note.content, color: generatePastelColor(), - fileId: currentFile, + fileId, vaultId: vaultId || "", createdAt: new Date(), embeddingStatus: null, @@ -483,33 +496,12 @@ export const NotesPanel = memo(function NotesPanel({ )}
)} -
- Math.abs(velocity) > 1000, - exit: (velocity) => Math.abs(velocity) < 100, - }} - customScrollParent={notesContainerRef.current!} - /> -
+ {memoizedVirtuoso}
)}
- +
) }) diff --git a/packages/morph/components/parser/codemirror/index.ts b/packages/morph/components/parser/codemirror/index.ts index 32ac21e..cb453d6 100644 --- a/packages/morph/components/parser/codemirror/index.ts +++ b/packages/morph/components/parser/codemirror/index.ts @@ -1,4 +1,5 @@ import frontmatter from "./frontmatter" -import { syntaxHighlighting, theme } from "./themes" import { search } from "./search" +import { syntaxHighlighting, theme } from "./themes" + export { frontmatter, syntaxHighlighting, theme, search } diff --git a/packages/morph/components/parser/codemirror/search.tsx b/packages/morph/components/parser/codemirror/search.tsx index 5589063..304d8a0 100644 --- a/packages/morph/components/parser/codemirror/search.tsx +++ b/packages/morph/components/parser/codemirror/search.tsx @@ -55,7 +55,7 @@ export function search() { // Dispatch effect to close panel view.dispatch({ - effects: [toggleSearchPanel.of(false)] + effects: [toggleSearchPanel.of(false)], }) // Focus the editor @@ -114,7 +114,9 @@ export function search() { run: (view: EditorView) => { // If panel is already open, focus it if (panelOpen) { - const input = view.dom.querySelector(".cm-search-panel-container input") as HTMLInputElement + const input = view.dom.querySelector( + ".cm-search-panel-container input", + ) as HTMLInputElement if (input) input.focus() return true } diff --git a/packages/morph/components/rails.tsx b/packages/morph/components/rails.tsx index 033e5bd..e26991e 100644 --- a/packages/morph/components/rails.tsx +++ b/packages/morph/components/rails.tsx @@ -821,14 +821,14 @@ export default memo(function Rails({ width: isExpanded ? "16rem" : "3.05rem", }, transition: { + duration: 0, type: "tween", - duration: shouldAnimate ? 0.2 : 0.01, // Almost instant when not visible or reduced motion preferred ease: "easeOut", layoutDependency: false, willChange: "width", }, }), - [isExpanded, shouldAnimate], + [isExpanded], ) // Memoize the vault icons buttons to prevent re-renders when toggling keyboard shortcuts diff --git a/packages/morph/components/reasoning-panel.tsx b/packages/morph/components/reasoning-panel.tsx index 1af0c46..1bba6db 100644 --- a/packages/morph/components/reasoning-panel.tsx +++ b/packages/morph/components/reasoning-panel.tsx @@ -40,7 +40,7 @@ const ReasoningHeader = memo(function ReasoningHeader({
@@ -51,7 +51,7 @@ const ReasoningHeader = memo(function ReasoningHeader({
{isHovering ? ( @@ -76,7 +76,6 @@ interface ReasoningPanelProps { isStreaming: boolean isComplete: boolean reasoningId: string - currentFile?: string vaultId?: string shouldExpand: boolean elapsedTime?: number @@ -89,7 +88,6 @@ export const ReasoningPanel = memo(function ReasoningPanel({ isStreaming, isComplete, reasoningId, - currentFile, vaultId, shouldExpand = false, elapsedTime = 0, @@ -129,13 +127,13 @@ export const ReasoningPanel = memo(function ReasoningPanel({ // Save reasoning to database when complete, not during streaming useEffect(() => { - if (!isStreaming && isComplete && reasoning && currentFile && vaultId) { + if (!isStreaming && isComplete && reasoning && vaultId) { db.update(schema.reasonings) .set({ content: reasoning }) .where(eq(schema.reasonings.id, reasoningId)) .execute() } - }, [isStreaming, isComplete, reasoning, currentFile, vaultId, reasoningId, db]) + }, [isStreaming, isComplete, reasoning, vaultId, reasoningId, db]) // Auto-scroll to bottom when content changes and panel is expanded useEffect(() => { @@ -168,17 +166,6 @@ export const ReasoningPanel = memo(function ReasoningPanel({ onExpandChange?.(newExpandState) }, [isExpanded, onExpandChange]) - // Format the duration nicely - const formattedDuration = (seconds: number) => { - if (seconds < 60) { - return `${seconds} second${seconds !== 1 ? "s" : ""}` - } else { - const minutes = Math.floor(seconds / 60) - const remainingSeconds = seconds % 60 - return `${minutes} minute${minutes !== 1 ? "s" : ""} ${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}` - } - } - // Virtualized text rendering for performance const renderReasoningText = useMemo(() => { if (!reasoning) return null @@ -221,7 +208,7 @@ export const ReasoningPanel = memo(function ReasoningPanel({ className={cn( "whitespace-pre-wrap ml-2 p-2 border-l-2 border-muted overflow-y-auto scrollbar-hidden max-h-72 transition-all duration-500 ease-in-out", "text-xs text-muted-foreground", - isExpanded ? "opacity-100 h-auto" : "opacity-0 h-0" + isExpanded ? "opacity-100 h-auto" : "opacity-0 h-0", )} > {renderReasoningText} diff --git a/packages/morph/context/authors.tsx b/packages/morph/context/authors.tsx index 7faf9ea..d8446ae 100644 --- a/packages/morph/context/authors.tsx +++ b/packages/morph/context/authors.tsx @@ -22,14 +22,9 @@ const initialAuthorTasksState: AuthorTasksState = { } const AuthorTasksStateContext = createContext(undefined) -const AuthorTasksDispatchContext = createContext | undefined>( - undefined -) +const AuthorTasksDispatchContext = createContext | undefined>(undefined) -function authorTasksReducer( - state: AuthorTasksState, - action: AuthorTasksAction, -): AuthorTasksState { +function authorTasksReducer(state: AuthorTasksState, action: AuthorTasksAction): AuthorTasksState { switch (action.type) { case "ADD_TASK": { // Only add the task if it's not already in the list diff --git a/packages/morph/context/file-restoration.tsx b/packages/morph/context/file-restoration.tsx index f4d8410..3e32bdb 100644 --- a/packages/morph/context/file-restoration.tsx +++ b/packages/morph/context/file-restoration.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useState } from "react" interface RestoredFileData { file: File fileHandle: FileSystemFileHandle - fileName: string + fileId: string content: string handleId: string } diff --git a/packages/morph/context/loading.tsx b/packages/morph/context/loading.tsx new file mode 100644 index 0000000..2fcc28b --- /dev/null +++ b/packages/morph/context/loading.tsx @@ -0,0 +1,92 @@ +"use client" + +import * as React from "react" +import { createContext, useCallback, useContext, useReducer } from "react" + +// Define loading state interface +interface LoadingState { + isNotesLoading: boolean + isNotesRecentlyGenerated: boolean +} + +// Define action types +type LoadingAction = + | { type: "SET_NOTES_LOADING"; payload: boolean } + | { type: "SET_NOTES_RECENTLY_GENERATED"; payload: boolean } + | { type: "RESET_ALL" } + +// Define reducer function +function loadingReducer(state: LoadingState, action: LoadingAction): LoadingState { + switch (action.type) { + case "SET_NOTES_LOADING": + return { ...state, isNotesLoading: action.payload } + case "SET_NOTES_RECENTLY_GENERATED": + return { ...state, isNotesRecentlyGenerated: action.payload } + case "RESET_ALL": + return { ...initialLoadingState } + default: + return state + } +} + +// Initial state +const initialLoadingState: LoadingState = { + isNotesLoading: false, + isNotesRecentlyGenerated: true, +} + +// Define context type +interface LoadingContextType { + state: LoadingState + setNotesLoading: (isLoading: boolean) => void + setNotesRecentlyGenerated: (isRecentlyGenerated: boolean) => void + resetLoadingState: () => void +} + +// Create context +const LoadingContext = createContext(null) + +// Provider props +interface LoadingProviderProps { + children: React.ReactNode +} + +// Create provider component +export function LoadingProvider({ children }: LoadingProviderProps) { + const [state, dispatch] = useReducer(loadingReducer, initialLoadingState) + + // Action dispatchers + const setNotesLoading = useCallback((isLoading: boolean) => { + dispatch({ type: "SET_NOTES_LOADING", payload: isLoading }) + }, []) + + const setNotesRecentlyGenerated = useCallback((isRecentlyGenerated: boolean) => { + dispatch({ type: "SET_NOTES_RECENTLY_GENERATED", payload: isRecentlyGenerated }) + }, []) + + const resetLoadingState = useCallback(() => { + dispatch({ type: "RESET_ALL" }) + }, []) + + // Create memoized value + const value = React.useMemo( + () => ({ + state, + setNotesLoading, + setNotesRecentlyGenerated, + resetLoadingState, + }), + [state, setNotesLoading, setNotesRecentlyGenerated, resetLoadingState] + ) + + return {children} +} + +// Create hook for consuming the context +export function useLoading() { + const context = useContext(LoadingContext) + if (context === null) { + throw new Error("useLoading must be used within a LoadingProvider") + } + return context +} diff --git a/packages/morph/context/providers.tsx b/packages/morph/context/providers.tsx index 73a1273..ca0404c 100644 --- a/packages/morph/context/providers.tsx +++ b/packages/morph/context/providers.tsx @@ -1,6 +1,6 @@ "use client" -import { applyPgLiteMigrations, initializeDb } from "@/db" +import { applyMigrations, initialize } from "@/db" import migrations from "@/generated/migrations.json" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" @@ -9,7 +9,6 @@ import type React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import PixelatedLoading from "@/components/landing/pixelated-loading" -import { TooltipProvider } from "@/components/ui/tooltip" import { AuthorTasksProvider } from "@/context/authors" import { MorphPgLite, PGliteProvider } from "@/context/db" @@ -58,9 +57,9 @@ async function restoreLastFile(vaultId: string, getHandle: any) { return { file, fileHandle, - fileName: file.name, content: await file.text(), handleId: lastFileInfo.handleId, + fileId: lastFileInfo.fileId || "", } } catch (error) { console.error("Error pre-loading file:", error) @@ -114,48 +113,28 @@ export default memo(function ClientProvider({ children }: ClientProviderProps) { const [contentReady, setContentReady] = useState(false) useEffect(() => { - async function setupDbAndMigrate() { + async function setup() { setIsDbLoading(true) setLoadingProgress(0) try { - // Simulate progress updates for 0-70% with faster intervals - const progressInterval = setInterval(() => { - setLoadingProgress((prev) => { - const newProgress = prev + 0.05 // Slower increment to reduce state changes - return newProgress > 0.7 ? 0.7 : newProgress - }) - }, 100) // Longer interval to reduce state change frequency - - const dbInstance = await initializeDb() - await applyPgLiteMigrations(dbInstance, migrations) - - clearInterval(progressInterval) - - // Set DB instance first before progressing further - setDb(dbInstance) - - // After a short delay, continue progress - setTimeout(() => { - setLoadingProgress(0.8) // DB is ready at 80% - - // Stagger the remaining progress updates - setTimeout(() => { - setLoadingProgress(0.9) - setTimeout(() => { - setLoadingProgress(1) - }, 50) - }, 100) - }, 150) + // Show some initial progress + setLoadingProgress(0.3) + + await initialize().then(async (db) => { + await applyMigrations(db, migrations) + // Set DB instance and finish loading + setDb(db) + setLoadingProgress(1) + }) } catch (err) { console.error("Error initializing database:", err) - setLoadingProgress(1) // Move to 100% even on error so UI can show + setLoadingProgress(1) } finally { setIsDbLoading(false) } } - - setupDbAndMigrate() + setup() }, []) // Handle transition from loading to content @@ -212,9 +191,7 @@ export default memo(function ClientProvider({ children }: ClientProviderProps) { enableSystem disableTransitionOnChange > - - {children} - + {children} diff --git a/packages/morph/db/index.ts b/packages/morph/db/index.ts index ac766ff..74c5510 100644 --- a/packages/morph/db/index.ts +++ b/packages/morph/db/index.ts @@ -8,40 +8,30 @@ import { MorphPgLite } from "@/context/db" export * from "@/db/schema" export * from "@/db/interfaces" -export const PGLITE_DB_NAME = "morph-pglite" +const PGLITE_DB_NAME = "morph-pglite" // Singleton instance and promise -let db: MorphPgLite | null = null // Use PGlite type directly -let initPromise: Promise | null = null - -// Function to get the initialized PGlite instance (singleton pattern) -// This function ONLY ensures the PGlite client is created and extensions are loaded. -// It does NOT handle schema creation/migration. -export async function initializeDb(): Promise { - if (db) { - return db - } - if (!initPromise) { - initPromise = (async () => { - try { - const pg = await PGlite.create({ - fs: new IdbFs(PGLITE_DB_NAME), - relaxedDurability: true, - extensions: { live, vector }, - }) - // Ensure vector extension exists - this is lightweight - await pg.exec(`CREATE EXTENSION IF NOT EXISTS vector;`) - db = pg - return pg - } catch (error) { - console.error("getDbInstance: Failed to initialize PGlite client:", error) - db = null // Reset instance on failure - initPromise = null // Reset promise on failure - throw error - } - })() +let db: MorphPgLite | null = null + +export async function initialize() { + if (db) return db + + try { + const pg = (await PGlite.create({ + fs: new IdbFs(PGLITE_DB_NAME), + relaxedDurability: true, + extensions: { live, vector }, + })) as MorphPgLite + + // Ensure vector extension exists - this is lightweight + await pg.exec(`CREATE EXTENSION IF NOT EXISTS vector;`) + db = pg + return pg + } catch (error) { + db = null + console.error("getDbInstance: Failed to initialize PGlite client:", error) + throw new Error("Failed to initialize database") } - return initPromise } // --- Browser Migration Runner --- // @@ -85,7 +75,7 @@ async function recordMigration(db: MorphPgLite, hash: string) { * @param db The initialized PGlite instance. * @param migrations The compiled migration data (array of MigrationMeta). */ -export async function applyPgLiteMigrations(db: MorphPgLite, migrations: MigrationMeta[]) { +export async function applyMigrations(db: MorphPgLite, migrations: MigrationMeta[]) { if (!migrations || migrations.length === 0) { return } diff --git a/packages/morph/hooks/use-fs-handles.ts b/packages/morph/hooks/use-fs-handles.ts index 2225124..7dbbb2d 100644 --- a/packages/morph/hooks/use-fs-handles.ts +++ b/packages/morph/hooks/use-fs-handles.ts @@ -1,3 +1,4 @@ +import { createId } from "@paralleldrive/cuid2" import Dexie from "dexie" import { useCallback } from "react" diff --git a/packages/morph/hooks/use-persisted-settings.tsx b/packages/morph/hooks/use-persisted-settings.tsx index 36338b3..dcb2555 100644 --- a/packages/morph/hooks/use-persisted-settings.tsx +++ b/packages/morph/hooks/use-persisted-settings.tsx @@ -1,83 +1,143 @@ -import { createContext, ReactNode, useContext, useEffect, useState } from "react" +import { + Dispatch, + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useReducer, + useRef, +} from "react" import { useVaultContext } from "@/context/vault" import { DEFAULT_SETTINGS, Settings } from "@/db/interfaces" +// Define action types +type SettingsAction = + | { type: "LOAD_SETTINGS"; payload: Settings } + | { type: "UPDATE_SETTINGS"; payload: Partial } + | { type: "SET_LOADED" } + +// Settings reducer function +function settingsReducer(state: { settings: Settings; isLoaded: boolean }, action: SettingsAction) { + switch (action.type) { + case "LOAD_SETTINGS": + return { + ...state, + settings: { ...DEFAULT_SETTINGS, ...action.payload }, + isLoaded: true, + } + case "UPDATE_SETTINGS": + return { + ...state, + settings: { ...state.settings, ...action.payload }, + } + case "SET_LOADED": + return { + ...state, + isLoaded: true, + } + default: + return state + } +} + // Create a context for settings interface SettingsContextType { settings: Settings - updateSettings: (newSettings: Partial) => Promise + updateSettings: (newSettings: Partial) => void isLoaded: boolean } const SettingsContext = createContext(undefined) +const SettingsDispatchContext = createContext | undefined>(undefined) // Create a provider component export function SettingsProvider({ children }: { children: ReactNode }) { - const [settings, setSettings] = useState(DEFAULT_SETTINGS) - const [isLoaded, setIsLoaded] = useState(false) + const [state, dispatch] = useReducer(settingsReducer, { + settings: DEFAULT_SETTINGS, + isLoaded: false, + }) + const { getActiveVault } = useVaultContext() + const loadedVaultIds = useRef>(new Set()) useEffect(() => { - const loadSettings = async () => { + const loadSettings = () => { try { const vault = getActiveVault() - if (!vault?.tree.handle) { - setIsLoaded(true) + if (!vault) { + dispatch({ type: "SET_LOADED" }) + return + } + + const vaultId = vault.id + + // Skip if we've already loaded settings for this vault + if (loadedVaultIds.current.has(vaultId)) { return } - // Get the .morph directory handle - const handle = vault.tree.handle as FileSystemDirectoryHandle - const morphDir = await handle.getDirectoryHandle(".morph", { create: true }) - const configFile = await morphDir.getFileHandle("config.json", { create: true }) - const file = await configFile.getFile() - const text = await file.text() + // Mark this vault as loaded + loadedVaultIds.current.add(vaultId) + + // Load settings from localStorage + const storageKey = `morph:vaults:${vaultId}` + const storedSettings = localStorage.getItem(storageKey) - if (text) { - const parsedSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(text) } - setSettings(parsedSettings) + if (storedSettings) { + const parsedSettings = JSON.parse(storedSettings) + dispatch({ type: "LOAD_SETTINGS", payload: parsedSettings }) + } else { + dispatch({ type: "LOAD_SETTINGS", payload: DEFAULT_SETTINGS }) } } catch (error) { console.error("Failed to load settings:", error) - } finally { - setIsLoaded(true) + dispatch({ type: "SET_LOADED" }) } } loadSettings() }, [getActiveVault]) - const updateSettings = async (newSettings: Partial) => { - try { - const vault = getActiveVault() - if (!vault?.tree.handle) return + const updateSettings = useCallback( + (newSettings: Partial) => { + try { + const vault = getActiveVault() + if (!vault) return + + // Update state through reducer + dispatch({ type: "UPDATE_SETTINGS", payload: newSettings }) - const updated = { ...settings, ...newSettings } - setSettings(updated) + // Set a localStorage flag if vim mode is changed + if (newSettings.vimMode !== undefined && newSettings.vimMode !== state.settings.vimMode) { + localStorage.setItem("morph:vim-mode-changed", "true") + localStorage.setItem("morph:vim-mode-value", newSettings.vimMode ? "true" : "false") + } - // Set a localStorage flag if vim mode is changed - if (newSettings.vimMode !== undefined && newSettings.vimMode !== settings.vimMode) { - localStorage.setItem('morph:vim-mode-changed', 'true') - localStorage.setItem('morph:vim-mode-value', newSettings.vimMode ? 'true' : 'false') + // Save to localStorage + const storageKey = `morph:vaults:${vault.id}` + const updatedSettings = { ...state.settings, ...newSettings } + localStorage.setItem(storageKey, JSON.stringify(updatedSettings)) + } catch (error) { + console.error("Failed to save settings:", error) } + }, + [getActiveVault, state.settings], + ) - // Save to .morph/config.json - const handle = vault.tree.handle as FileSystemDirectoryHandle - const morphDir = await handle.getDirectoryHandle(".morph", { create: true }) - const configFile = await morphDir.getFileHandle("config.json", { create: true }) - const writable = await configFile.createWritable() - await writable.write(JSON.stringify(updated, null, 2)) - await writable.close() - } catch (error) { - console.error("Failed to save settings:", error) - } + const contextValue = { + settings: state.settings, + updateSettings, + isLoaded: state.isLoaded, } return ( - - {children} + + + {children} + ) } diff --git a/packages/morph/package.json b/packages/morph/package.json index 4bea9b5..39049ea 100644 --- a/packages/morph/package.json +++ b/packages/morph/package.json @@ -22,6 +22,7 @@ "@codemirror/view": "^6.36.5", "@electric-sql/pglite": "^0.2.17", "@electric-sql/pglite-react": "^0.2.17", + "@electric-sql/pglite-repl": "^0.2.17", "@hookform/resolvers": "^4.1.3", "@lezer/highlight": "^1.2.1", "@paralleldrive/cuid2": "github:paralleldrive/cuid2", diff --git a/packages/morph/services/authors.ts b/packages/morph/services/authors.ts index a2b6c6b..72db921 100644 --- a/packages/morph/services/authors.ts +++ b/packages/morph/services/authors.ts @@ -73,9 +73,9 @@ async function submitAuthorTask( temperature: options.temperature || 0.7, max_tokens: options.max_tokens || 16384, search_backend: "exa", - num_search_results: 3, - use_tool: options.use_tool || false, - }) + num_search_results: 3, + use_tool: options.use_tool || false, + }) .then((resp) => resp.data) .catch((err) => { console.error( diff --git a/packages/morph/services/essays.ts b/packages/morph/services/essays.ts index 7693d9b..a0dec60 100644 --- a/packages/morph/services/essays.ts +++ b/packages/morph/services/essays.ts @@ -88,10 +88,11 @@ async function submitFileEmbeddingTask( content: string, ): Promise { return axios - .post( - `${API_ENDPOINT}/essays/submit`, - { vault_id: vaultId, file_id: fileId, content }, - ) + .post(`${API_ENDPOINT}/essays/submit`, { + vault_id: vaultId, + file_id: fileId, + content, + }) .then((resp) => resp.data) .catch((err) => { console.error( @@ -259,10 +260,10 @@ async function saveFileEmbeddings( } // Hook for polling embedding status and handling completion -export function useQueryEssayEmbeddingStatus(taskId: string | null | undefined) { - const client = usePGlite() - const db = drizzle({ client, schema }) - +export function useQueryEssayEmbeddingStatus( + taskId: string | null | undefined, + db: PgliteDatabase, +) { return useQuery({ queryKey: ["essayEmbedding", taskId], queryFn: async () => { @@ -462,10 +463,7 @@ export async function submitFileForEmbedding( } // Hook to process pending file embeddings -export function useProcessPendingEssayEmbeddings() { - const client = usePGlite() - const db = drizzle({ client, schema }) - +export function useProcessPendingEssayEmbeddings(db: PgliteDatabase) { return useMutation({ mutationFn: async (options?: { addTask?: (taskId: string) => void diff --git a/packages/morph/services/notes.ts b/packages/morph/services/notes.ts index 5dfbfa0..9f8b2b7 100644 --- a/packages/morph/services/notes.ts +++ b/packages/morph/services/notes.ts @@ -4,9 +4,7 @@ import { TaskStatusResponse } from "@/services/constants" import { useMutation, useQuery } from "@tanstack/react-query" import axios from "axios" import { and, eq, isNull, not } from "drizzle-orm" -import { PgliteDatabase, drizzle } from "drizzle-orm/pglite" - -import { usePGlite } from "@/context/db" +import { PgliteDatabase } from "drizzle-orm/pglite" import type { Note } from "@/db/interfaces" import * as schema from "@/db/schema" @@ -196,17 +194,16 @@ async function saveNoteEmbedding( } // Hook for polling embedding status and handling completion -export function useQueryNoteEmbeddingStatus(taskId: string | null | undefined) { - const client = usePGlite() - const db = drizzle({ client, schema }) - +export function useQueryNoteEmbeddingStatus( + taskId: string | null | undefined, + db: PgliteDatabase, +) { return useQuery({ queryKey: ["noteEmbedding", taskId], queryFn: async () => { if (!taskId) { throw new Error("Task ID is required") } - try { // Check task status const statusData = await checkNoteEmbeddingTask(taskId) @@ -399,10 +396,7 @@ export async function submitNotesForEmbedding( } // Hook to process notes that need embedding -export function useProcessPendingEmbeddings() { - const client = usePGlite() - const db = drizzle({ client, schema }) - +export function useProcessPendingEmbeddings(db: PgliteDatabase) { return useMutation({ mutationFn: async (options?: { addTask?: (taskId: string) => void }) => { const { addTask } = options || {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdb266c..56c9631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ importers: '@electric-sql/pglite-react': specifier: ^0.2.17 version: 0.2.17(@electric-sql/pglite@0.2.17)(react@19.1.0) + '@electric-sql/pglite-repl': + specifier: ^0.2.17 + version: 0.2.17(@babel/runtime@7.27.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.2.17)(codemirror@6.0.1) '@hookform/resolvers': specifier: ^4.1.3 version: 4.1.3(react-hook-form@7.55.0(react@19.1.0)) @@ -1125,6 +1128,14 @@ packages: '@electric-sql/pglite': ^0.2.17 react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + '@electric-sql/pglite-repl@0.2.17': + resolution: {integrity: sha512-gvqYrTc+vPn26MGWH55zkbDQWPETiZ/UNp9OV6gPn7dqfKjnyCRccopmc07zA16Kmv5mBHxI30mab1fq0lTCYA==} + peerDependencies: + '@electric-sql/pglite': ^0.2.17 + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@electric-sql/pglite@0.2.17': resolution: {integrity: sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==} @@ -3564,6 +3575,12 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' + '@uiw/codemirror-theme-github@4.23.10': + resolution: {integrity: sha512-jTg2sHAcU1d+8x0O+EBDI71rtJ8PWKIW8gzy+SW4wShQTAdsqGHk5y1ynt3KIeoaUkqngLqAK4SkhPaUKlqZqg==} + + '@uiw/codemirror-theme-xcode@4.23.10': + resolution: {integrity: sha512-meYMRLr0mTqGZerihSTKpjceRkxbFfpnXX9RyDZFwloydxzWY/6iGEt1/RMY+cWoxgjBYSiunqtU0qgQH0WAuA==} + '@uiw/codemirror-themes@4.23.10': resolution: {integrity: sha512-dU0UgEEgEXCAYpxuVDQ6fovE82XsqgHZckTJOH6Bs8xCi3Z7dwBKO4pXuiA8qGDwTOXOMjSzfi+pRViDm7OfWw==} peerDependencies: @@ -5918,6 +5935,9 @@ packages: pseudocode@2.4.1: resolution: {integrity: sha512-PUslrULyVbqk72+L4D4dvDwD8KPOGazSM9oQhB9a05ab+BPsvwFy6rm/pX6vSFp0IiCa1Isi/lmNE2oHqmr+rw==} + psql-describe@0.1.6: + resolution: {integrity: sha512-cZqmsO1FOTmKZFnwbZxViPzEkH/Kyof/t1O2QI25oN5TEexXl6AXVFNIYpoIVBGm2Ic+ImJDR760zUgBMBv+KQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7410,6 +7430,31 @@ snapshots: '@electric-sql/pglite': 0.2.17 react: 19.1.0 + '@electric-sql/pglite-repl@0.2.17(@babel/runtime@7.27.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@electric-sql/pglite@0.2.17)(codemirror@6.0.1)': + dependencies: + '@codemirror/autocomplete': 6.18.6 + '@codemirror/commands': 6.8.1 + '@codemirror/lang-sql': 6.8.0 + '@codemirror/language': 6.11.0 + '@codemirror/view': 6.36.5 + '@electric-sql/pglite-react': 0.2.17(@electric-sql/pglite@0.2.17)(react@19.1.0) + '@uiw/codemirror-theme-github': 4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + '@uiw/codemirror-theme-xcode': 4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + '@uiw/codemirror-themes': 4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + '@uiw/react-codemirror': 4.23.10(@babel/runtime@7.27.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.10)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.5)(codemirror@6.0.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + psql-describe: 0.1.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@electric-sql/pglite': 0.2.17 + transitivePeerDependencies: + - '@babel/runtime' + - '@codemirror/lint' + - '@codemirror/search' + - '@codemirror/state' + - '@codemirror/theme-one-dark' + - codemirror + '@electric-sql/pglite@0.2.17': {} '@emnapi/core@1.4.0': @@ -9535,6 +9580,22 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/view': 6.36.5 + '@uiw/codemirror-theme-github@4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)': + dependencies: + '@uiw/codemirror-themes': 4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-theme-xcode@4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)': + dependencies: + '@uiw/codemirror-themes': 4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + '@uiw/codemirror-themes@4.23.10(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)': dependencies: '@codemirror/language': 6.11.0 @@ -12338,6 +12399,8 @@ snapshots: optionalDependencies: mathjax: 3.2.2 + psql-describe@0.1.6: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} From df3e5c358190b22559e145d9e5e18a233658a210 Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Mon, 7 Apr 2025 17:50:02 -0400 Subject: [PATCH 04/12] perf: improved separation of rendering concern Signed-off-by: Aaron Pham --- packages/morph/app/layout.tsx | 2 +- packages/morph/components/editor.tsx | 1422 +--------------- packages/morph/components/note-panel.tsx | 1526 +++++++++++++++--- packages/morph/components/rails.tsx | 16 +- packages/morph/components/search-command.tsx | 8 +- packages/morph/components/ui/sonner.tsx | 25 + packages/morph/components/ui/toast.tsx | 129 -- packages/morph/components/ui/toaster.tsx | 35 - packages/morph/context/providers.tsx | 11 +- packages/morph/db/index.ts | 1 + packages/morph/hooks/use-toast.ts | 189 --- packages/morph/package.json | 1 + pnpm-lock.yaml | 14 + 13 files changed, 1436 insertions(+), 1943 deletions(-) create mode 100644 packages/morph/components/ui/sonner.tsx delete mode 100644 packages/morph/components/ui/toast.tsx delete mode 100644 packages/morph/components/ui/toaster.tsx delete mode 100644 packages/morph/hooks/use-toast.ts diff --git a/packages/morph/app/layout.tsx b/packages/morph/app/layout.tsx index 2ce38e0..66f60b1 100644 --- a/packages/morph/app/layout.tsx +++ b/packages/morph/app/layout.tsx @@ -3,7 +3,7 @@ import PlausibleProvider from "next-plausible" import Script from "next/script" import type React from "react" -import { Toaster } from "@/components/ui/toaster" +import { Toaster } from "@/components/ui/sonner" import ClientProvider from "@/context/providers" diff --git a/packages/morph/components/editor.tsx b/packages/morph/components/editor.tsx index 33b8be4..5219276 100644 --- a/packages/morph/components/editor.tsx +++ b/packages/morph/components/editor.tsx @@ -1,22 +1,9 @@ "use client" -import { cn, sanitizeStreamingContent, toJsx } from "@/lib" +import { cn, toJsx } from "@/lib" import { generatePastelColor } from "@/lib/notes" import { groupNotesByDate } from "@/lib/notes" -import { - GeneratedNote, - NewlyGeneratedNotes, - SuggestionRequest, - SuggestionResponse, - checkAgentAvailability, - checkAgentHealth, -} from "@/services/agents" import { checkFileHasEmbeddings, useProcessPendingEssayEmbeddings } from "@/services/essays" -import { - checkNoteHasEmbedding, - submitNoteForEmbedding, - useProcessPendingEmbeddings, -} from "@/services/notes" import { defaultKeymap, history, historyKeymap } from "@codemirror/commands" import { markdown, markdownLanguage } from "@codemirror/lang-markdown" import { languages } from "@codemirror/language-data" @@ -37,17 +24,17 @@ import * as React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { DndProvider } from "react-dnd" import { HTML5Backend } from "react-dnd-html5-backend" +import { toast } from "sonner" import { useDebouncedCallback } from "use-debounce" import { AuthorProcessor } from "@/components/author-processor" import ContextNotes from "@/components/context-notes" import { CustomDragLayer, EditorDropTarget } from "@/components/dnd" import { EssayEmbeddingProcessor } from "@/components/essay-embedding-processor" -import { fileField, mdToHtml } from "@/components/markdown-inline" -import { setFile } from "@/components/markdown-inline" +import { fileField, mdToHtml, setFile } from "@/components/markdown-inline" import { NoteEmbeddingProcessor } from "@/components/note-embedding-processor" import { DroppedNoteGroup } from "@/components/note-group" -import { NotesPanel, StreamingNote } from "@/components/note-panel" +import { NotesPanel } from "@/components/note-panel" import { theme as editorTheme, frontmatter, md, syntaxHighlighting } from "@/components/parser" import { search } from "@/components/parser/codemirror" import Rails from "@/components/rails" @@ -58,49 +45,19 @@ import { DotIcon } from "@/components/ui/icons" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { usePGlite } from "@/context/db" -import { useEmbeddingTasks, useEssayEmbeddingTasks } from "@/context/embedding" +import { useEssayEmbeddingTasks } from "@/context/embedding" import { useRestoredFile } from "@/context/file-restoration" -import { LoadingProvider, useLoading } from "@/context/loading" import { SearchProvider } from "@/context/search" -import { SteeringProvider, SteeringSettings } from "@/context/steering" +import { SteeringProvider } from "@/context/steering" import { useVaultContext } from "@/context/vault" import { verifyHandle } from "@/context/vault-reducer" import useFsHandles from "@/hooks/use-fs-handles" import usePersistedSettings from "@/hooks/use-persisted-settings" -import { useToast } from "@/hooks/use-toast" import type { FileSystemTreeNode, Note, Vault } from "@/db/interfaces" import * as schema from "@/db/schema" -// After all the imports but before the logNotesForFile function, add the synchronizer component: - -/** - * Component to sync editor loading state with the loading context - */ -interface LoadingContextSynchronizerProps { - isNotesLoading: boolean - isNotesRecentlyGenerated: boolean -} - -function LoadingContextSynchronizer({ - isNotesLoading, - isNotesRecentlyGenerated, -}: LoadingContextSynchronizerProps) { - const { setNotesLoading, setNotesRecentlyGenerated } = useLoading() - - // Sync state to context - useEffect(() => { - setNotesLoading(isNotesLoading) - }, [isNotesLoading, setNotesLoading]) - - useEffect(() => { - setNotesRecentlyGenerated(isNotesRecentlyGenerated) - }, [isNotesRecentlyGenerated, setNotesRecentlyGenerated]) - - return null -} - // Add this utility function after the imports but before the Editor component /** * Utility to query and log all notes for a specific file @@ -175,18 +132,6 @@ interface EditorProps { vaults: Vault[] } -interface ReasoningHistory { - id: string - content: string - timestamp: Date - noteIds: string[] - reasoningElapsedTime: number - authors?: string[] - tonality?: Record - temperature?: number - numSuggestions?: number -} - export interface AuthorRequest { essay: string authors?: string[] @@ -213,10 +158,8 @@ function saveLastFileInfo(vaultId: string | null, fileId: string, handleId: stri } } -// Replace the wrapper component with a direct export of EditorComponent export default memo(function Editor({ vaultId, vaults }: EditorProps) { const { theme } = useTheme() - const { toast } = useToast() const { storeHandle } = useFsHandles() const { restoredFile, setRestoredFile } = useRestoredFile() const { settings } = usePersistedSettings() @@ -225,31 +168,17 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const [isEditMode, setIsEditMode] = useState(true) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [previewNode, setPreviewNode] = useState(null) - const [isNotesLoading, setIsNotesLoading] = useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [currentFileHandle, setCurrentFileHandle] = useState(null) - const [scanAnimationComplete, setScanAnimationComplete] = useState(false) const [showEphemeralBanner, setShowEphemeralBanner] = useState(false) const codeMirrorViewRef = useRef(null) const readingModeRef = useRef(null) const [showNotes, setShowNotes] = useState(false) const [notes, setNotes] = useState([]) const [markdownContent, setMarkdownContent] = useState("") - const [notesError, setNotesError] = useState(null) const [droppedNotes, setDroppedNotes] = useState([]) - const [streamingReasoning, setStreamingReasoning] = useState("") - const [reasoningComplete, setReasoningComplete] = useState(false) - const [currentReasoningElapsedTime, setCurrentReasoningElapsedTime] = useState(0) - const [lastNotesGeneratedTime, setLastNotesGeneratedTime] = useState(null) const notesContainerRef = useRef(null) - const [reasoningHistory, setReasoningHistory] = useState([]) - const [currentReasoningId, setCurrentReasoningId] = useState("") - const [currentlyGeneratingDateKey, setCurrentlyGeneratingDateKey] = useState(null) const [isStackExpanded, setIsStackExpanded] = useState(false) - const [streamingSuggestionColors, setStreamingSuggestionColors] = useState([]) - // Add a state to track current generation notes - const [currentGenerationNotes, setCurrentGenerationNotes] = useState([]) - const [streamingNotes, setStreamingNotes] = useState([]) const [vimMode, setVimMode] = useState(settings.vimMode ?? false) // Add a ref to track if we've already attempted to restore a file const fileRestorationAttempted = useRef(false) @@ -263,10 +192,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const client = usePGlite() const db = drizzle({ client, schema }) - const vault = vaults.find((v) => v.id === vaultId) - - // Add the process pending embeddings mutation - const processEmbeddings = useProcessPendingEmbeddings(db) + const vault: Vault = vaults.find((v) => v.id === vaultId) // Add the process pending embeddings mutation for essays const processEssayEmbeddings = useProcessPendingEssayEmbeddings(db) @@ -277,9 +203,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Create a ref for the keyboard handler to ensure stable identity const keyHandlerRef = useRef<(e: KeyboardEvent) => void>(() => {}) - // Add this ref to track if we've processed embeddings for the current file - const embeddingProcessedRef = useRef(null) - // Add a ref to track if we've processed essay embeddings for the current file const essayEmbeddingProcessedRef = useRef(null) @@ -287,8 +210,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const embeddingTimeoutRef = useRef(null) // Get the task actions for adding tasks - const { addTask /*pendingTaskIds*/ } = useEmbeddingTasks() - const { addTask: addEssayTask /*pendingTaskIds: essayPendingTaskIds*/ } = useEssayEmbeddingTasks() + const { addTask: addEssayTask } = useEssayEmbeddingTasks() useEffect(() => { if (restoredFile && restoredFile!.fileId) setCurrentFileId(restoredFile!.fileId) @@ -407,327 +329,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { [debouncedUpdatePreview, vaultId, vault, db], ) - // Helper function to load file metadata (notes, reasonings, etc.) - const loadFileMetadata = useCallback( - async (fileId: string) => { - if (!vault || fileId === null) return - - try { - console.log(`[Notes Debug] Starting to load metadata for file ${fileId}`) - - // Find the file in database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), - }) - - // If file doesn't exist in DB, create it - if (!dbFile) { - console.error(`File ${fileId} does not exists in db. Something has gone very wrong`) - return - } - - // Only continue if we have a valid file reference - if (!dbFile) { - console.error("Failed to find or create file in database") - return - } - - // Fetch notes associated with this file in a single query - try { - // Query all notes for this file - const fileNotes = await db - .select() - .from(schema.notes) - .where(and(eq(schema.notes.fileId, dbFile.id), eq(schema.notes.vaultId, vaultId))) - - console.log( - `[Notes Debug] Retrieved ${fileNotes?.length || 0} notes from DB for file ${fileId}`, - ) - - if (fileNotes && fileNotes.length > 0) { - // Process notes and reasoning in parallel - // Separate notes into regular and dropped notes - const regularNotes = fileNotes.filter((note) => !note.dropped) - const droppedNotesList = fileNotes.filter((note) => note.dropped) - - console.log( - `[Notes Debug] Regular notes: ${regularNotes.length}, Dropped notes: ${droppedNotesList.length}`, - ) - - // Prepare notes for the UI - const uiReadyRegularNotes = regularNotes.map((note) => ({ - ...note, - color: note.color || generatePastelColor(), - lastModified: new Date(note.accessedAt), - })) - - const uiReadyDroppedNotes = droppedNotesList.map((note) => ({ - ...note, - color: note.color || generatePastelColor(), - lastModified: new Date(note.accessedAt), - })) - - // Reset note states before updating to avoid merging with previous file's notes - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - - let loadedReasoningHistory: ReasoningHistory[] = [] - - // In parallel, fetch and process reasoning if needed - const reasoningIds = [ - ...new Set(fileNotes.map((note) => note.reasoningId).filter(Boolean)), - ] as string[] // Explicitly type as string[] - if (reasoningIds.length > 0) { - console.log( - `[Notes Debug] Found ${reasoningIds.length} unique reasoning IDs, fetching reasoning`, - ) - const reasonings = await db - .select() - .from(schema.reasonings) - .where(inArray(schema.reasonings.id, reasoningIds)) - - console.log(`[Notes Debug] Retrieved ${reasonings?.length || 0} reasonings from DB`) - - if (reasonings && reasonings.length > 0) { - // Convert to ReasoningHistory format - loadedReasoningHistory = reasonings.map((r) => ({ - id: r.id, - content: r.content, - timestamp: r.createdAt, - noteIds: fileNotes.filter((n) => n.reasoningId === r.id).map((n) => n.id), - reasoningElapsedTime: r.duration, - authors: r.steering?.authors, - tonality: r.steering?.tonality, - temperature: r.steering?.temperature, - numSuggestions: r.steering?.numSuggestions, - })) - } - } - - // Update the state with fetched notes atomically to prevent flicker - // Ensure we are using the correct variables here - setNotes(uiReadyRegularNotes) - setDroppedNotes(uiReadyDroppedNotes) - setReasoningHistory(loadedReasoningHistory) - - console.log(`[Notes Debug] State updated after loading metadata:`, { - notesCount: uiReadyRegularNotes.length, - droppedNotesCount: uiReadyDroppedNotes.length, - reasoningHistoryCount: loadedReasoningHistory.length, - }) - } else { - console.log(`[Notes Debug] No notes found for file ${fileId}, resetting state`) - // Ensure state is reset even if no notes are found - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - } - - // After loading and setting up notes, perform a detailed query and log for verification - await logNotesForFile(db, dbFile.id, vaultId, "After loadFileMetadata") - - // Reset embedding processing flags for the new file - embeddingProcessedRef.current = null - essayEmbeddingProcessedRef.current = null - } catch (error) { - console.error("Error fetching notes for file:", error) - // Reset note states on error - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setNotes([]) - setDroppedNotes([]) - setReasoningHistory([]) - throw error - } - } catch (dbError) { - console.error("Error with database operations:", dbError) - throw dbError - } - }, - [db, vault, vaultId], - ) - - const toggleNotes = useCallback(async () => { - // Instead of using setState callback, directly use a variable for performance - const shouldShowNotes = !showNotes - - // If we're hiding the panel - if (!shouldShowNotes) { - // Reset reasoning state - setStreamingReasoning("") - setReasoningComplete(false) - setNotesError(null) // Reset error state when closing panel - - // Synchronize current generation notes with history if they exist - if (currentGenerationNotes.length > 0 && currentlyGeneratingDateKey) { - console.log( - `[Notes Debug] Syncing ${currentGenerationNotes.length} current generation notes to DB on panel close`, - ) - - // Process the notes synchronously, rather than in state updater - // Filter out any duplicates that might already exist - const notesToAdd = currentGenerationNotes.filter( - (note) => !notes.some((existingNote) => existingNote.id === note.id), - ) - - if (notesToAdd.length > 0) { - console.log(`[Notes Debug] Adding ${notesToAdd.length} new notes to state`) - // Add to notes and sort by creation date - const combined = [...notesToAdd, ...notes] - const sorted = combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - setNotes(sorted) - } - - // Save notes to database if the file isn't null and we have a vault - if (currentFileId !== null && vault) { - // We use a self-executing async function to avoid making the whole callback async - try { - console.log(`[Notes Debug] Saving notes to DB for file ${currentFileId}`) - // First, find the file in database - const dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.id, currentFileId), eq(files.vaultId, vaultId)), - }) - - if (!dbFile) { - console.error("Failed to find file in database when synchronizing notes") - return - } - - // Get the current reasoning for these notes - const currentReasoning = reasoningHistory.find((r) => r.id === currentReasoningId) - if (currentReasoning && currentReasoningId) { - // Check if reasoning exists in database - const existingReasoning = await db.query.reasonings.findFirst({ - where: eq(schema.reasonings.id, currentReasoningId), - }) - - // If reasoning doesn't exist yet, create it - if (!existingReasoning && currentReasoning) { - // Get note IDs for the reasoning - const noteIds = currentGenerationNotes.map((note) => note.id) - - console.log( - `[Notes Debug] Creating new reasoning in DB with ID ${currentReasoningId} and ${noteIds.length} note IDs`, - ) - - // Insert reasoning with properly typed steering (can be null) - await db.insert(schema.reasonings).values({ - id: currentReasoningId, - fileId: dbFile.id, - vaultId: vaultId, - content: currentReasoning.content, - noteIds: noteIds, - createdAt: currentReasoning.timestamp, - accessedAt: new Date(), - duration: currentReasoning.reasoningElapsedTime, - steering: currentGenerationNotes[0]?.steering || null, - }) - } else { - console.log( - `[Notes Debug] Reasoning ${currentReasoningId} already exists in DB, skipping creation`, - ) - } - } - - // For each note, ensure it's saved to database if not already - for (const note of currentGenerationNotes) { - // Check if note exists in database - const existingNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) - - if (!existingNote) { - console.log(`[Notes Debug] Saving new note to DB: ${note.id}`) - - // Insert note with properly typed steering (can be null) - await db.insert(schema.notes).values({ - id: note.id, - content: note.content, - color: note.color, - createdAt: note.createdAt, - accessedAt: new Date(), - dropped: note.dropped ?? false, - fileId: dbFile.id, - vaultId: note.vaultId, - reasoningId: note.reasoningId!, - steering: note.steering || null, - embeddingStatus: "in_progress", - embeddingTaskId: null, - }) - } else { - console.log(`[Notes Debug] Note ${note.id} already exists in DB, skipping creation`) - } - } - - // Process notes for embedding one by one and set up polling - for (const note of currentGenerationNotes) { - const hasEmbedding = await checkNoteHasEmbedding(db, note.id) - if (!hasEmbedding) { - console.log(`[Notes Debug] Submitting note ${note.id} for embedding`) - const result = await submitNoteForEmbedding(db, note) - - // If successful and we have a task ID, add it for polling - if (result) { - const updatedNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) - - if (updatedNote?.embeddingTaskId) { - addTask(updatedNote.embeddingTaskId) - console.log( - `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, - ) - } - } - } else { - console.log( - `[Notes Debug] Note ${note.id} already has embedding, skipping submission`, - ) - } - } - } catch (error) { - console.error("Failed to sync notes to database:", error) - } - } - - // Reset current generation state to prevent duplication when reopening - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - } - } else { - // When opening the notes panel, check for notes that need embeddings - // but only process once per file to avoid excessive processing - if (notes.length > 0 && !embeddingProcessedRef.current) { - console.log(`[Notes Debug] Processing embeddings for ${notes.length} notes`) - processEmbeddings.mutate({ addTask }) - embeddingProcessedRef.current = `${vault?.id || ""}:${currentFileId}` - } - } - - // Update state after all synchronous operations - setShowNotes(shouldShowNotes) - }, [ - vaultId, - vault, - currentGenerationNotes, - currentlyGeneratingDateKey, - currentFileId, - db, - currentReasoningId, - reasoningHistory, - processEmbeddings, - addTask, - notes, - showNotes, - ]) + const toggleNotes = useCallback(() => { + // Simple toggle function that just changes visibility + setShowNotes(!showNotes) + }, [showNotes]) const handleNoteDropped = useCallback( async (note: Note) => { @@ -735,20 +340,17 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { console.log(`[Notes Debug] Handling note drop for note ${note.id}`) // Ensure note has a color if it doesn't already - const noteWithColor = { + const droppedNote: Note = { ...note, - color: note.color || generatePastelColor(), dropped: true, - lastModified: new Date(), - // Preserve existing embedding status or set to "in_progress" if undefined - embeddingStatus: note.embeddingStatus || "in_progress", + accessedAt: new Date(), } // Update droppedNotes optimistically without triggering unnecessary motion setDroppedNotes((prev) => { - if (prev.find((n) => n.id === noteWithColor.id)) return prev + if (prev.find((n) => n.id === droppedNote.id)) return prev // Add note to the end of the array for proper scroll-to behavior - return [...prev, noteWithColor] + return [...prev, droppedNote] }) // Find the file in the database to get its ID @@ -771,542 +373,23 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { .update(schema.notes) .set({ dropped: true, - color: noteWithColor.color, - accessedAt: new Date(), // Update accessedAt timestamp + color: droppedNote.color, + accessedAt: droppedNote.accessedAt!, // Update accessedAt timestamp fileId: dbFile.id, - // Only update embeddingStatus if it's not already set - ...(noteWithColor.embeddingStatus !== "success" && { embeddingStatus: "in_progress" }), + ...(droppedNote.embeddingStatus !== "success" && { embeddingStatus: "in_progress" }), }) - .where(eq(schema.notes.id, noteWithColor.id)) + .where(eq(schema.notes.id, droppedNote.id)) console.log(`[Notes Debug] Successfully updated note ${note.id} as dropped in DB`) - - // After updating the note's dropped status, check if it needs embedding - // If it doesn't already have an embedding, submit it - const hasEmbedding = await checkNoteHasEmbedding(db, noteWithColor.id) - if (!hasEmbedding) { - console.log(`[Notes Debug] Note ${note.id} needs embedding, submitting...`) - - // Get the full note with all fields - const fullNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, noteWithColor.id), - }) - - if (fullNote) { - // Submit for embedding and get task ID - const result = await submitNoteForEmbedding(db, fullNote) - - // If successful, update the local state with the latest status - if (result) { - // Wait a small amount of time to ensure DB updates are complete - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Get the updated note with task ID - const updatedNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, noteWithColor.id), - }) - - if (updatedNote?.embeddingTaskId) { - addTask(updatedNote.embeddingTaskId) - console.log( - `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, - ) - - // Update dropped notes with the new status and task ID - setDroppedNotes((prev) => - prev.map((n) => - n.id === noteWithColor.id - ? { - ...n, - embeddingStatus: "in_progress", - embeddingTaskId: updatedNote.embeddingTaskId, - } - : n, - ), - ) - } - } else { - console.log(`[Notes Debug] Failed to submit note ${note.id} for embedding`) - - // Update dropped notes to show failure - setDroppedNotes((prev) => - prev.map((n) => - n.id === noteWithColor.id ? { ...n, embeddingStatus: "failure" } : n, - ), - ) - } - } - } else { - console.log(`[Notes Debug] Note ${note.id} already has embedding, marking as success`) - - // Update dropped notes to show success - setDroppedNotes((prev) => - prev.map((n) => (n.id === noteWithColor.id ? { ...n, embeddingStatus: "success" } : n)), - ) - } } catch (error) { console.error("Failed to update note dropped status:", error) // Update dropped notes to show failure setDroppedNotes((prev) => - prev.map((n) => (n.id === noteWithColor.id ? { ...n, embeddingStatus: "failure" } : n)), + prev.map((n) => (n.id === droppedNote.id ? { ...n, embeddingStatus: "failure" } : n)), ) } }, - [db, currentFileId, vault, addTask], - ) - - const fetchNewNotes = useCallback( - async ( - content: string, - numSuggestions: number, - steeringOptions?: { - authors?: string[] - tonality?: Record - temperature?: number - }, - ): Promise => { - try { - const apiEndpoint = process.env.NEXT_PUBLIC_API_ENDPOINT || "http://localhost:8000" - - const isAgentAvailable = await checkAgentAvailability() - if (!isAgentAvailable) { - throw new Error("Agent service is not available. Please try again later.") - } - - // Check agent health to ensure it's functioning properly - const healthStatus = await checkAgentHealth() - if (!healthStatus.healthy) { - const unhealthyServices = healthStatus.services - .filter((service) => !service.healthy) - .map((service) => `${service.name} (${service.error || "Unknown error"})`) - .join(", ") - - console.error("Service health check failed:", { - timestamp: healthStatus.timestamp, - overallHealth: healthStatus.healthy, - unhealthyServices, - allServices: healthStatus.services, - }) - - const errorMsg = `Services unavailable: ${unhealthyServices}` - toast({ - title: "Service Health Error", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - throw new Error(errorMsg) - } - - // Create a new reasoning ID for this generation - const reasoningId = createId() - - // Reset states for new generation - setStreamingReasoning("") - setReasoningComplete(false) - setCurrentReasoningElapsedTime(0) // Reset elapsed time at the start - setStreamingNotes([]) // Reset streaming notes - setScanAnimationComplete(false) // Reset scan animation state - - // Set a current date key for the new notes group with 15-second interval - const now = new Date() - const seconds = now.getSeconds() - const interval = Math.floor(seconds / 15) * 15 - const dateKey = `${now.toDateString()}-${now.getHours()}-${now.getMinutes()}-${interval}` - setCurrentlyGeneratingDateKey(dateKey) - - const max_tokens = 16384 - const essay = md(content).content - const request: SuggestionRequest = { - essay, - num_suggestions: numSuggestions, - temperature: steeringOptions?.temperature ?? 0.6, - max_tokens, - ...(steeringOptions?.authors && { authors: steeringOptions.authors }), - ...(steeringOptions?.tonality && { tonality: steeringOptions.tonality }), - usage: true, - } - - // Add dropped notes to the request if there are any - if (droppedNotes.length > 0) { - request.notes = droppedNotes.map((note) => ({ - vault_id: note.vaultId, - file_id: note.fileId, - note_id: note.id, - content: note.content, - })) - } - - // Start timing reasoning phase - const reasoningStartTime = Date.now() - let reasoningEndTime: number | null = null - - // Create streaming request - let response: Response - try { - response = await fetch(`${apiEndpoint}/suggests`, { - method: "POST", - headers: { Accept: "text/event-stream", "Content-Type": "application/json" }, - body: JSON.stringify(request), - }) - - if (!response.ok) { - const errorMsg = `Failed to fetch suggestions: ${response.statusText || "Unknown error"}` - toast({ - title: "Suggestion Error", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - throw new Error(errorMsg) - } - - if (!response.body) { - const errorMsg = "Response body is empty" - toast({ - title: "Empty Response", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - throw new Error(errorMsg) - } - } catch (suggestError: any) { - const errorMsg = `Failed to get suggestions: ${suggestError.message || "Unknown error"}` - toast({ - title: "Suggestion Error", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - throw new Error(errorMsg) - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - - // We'll collect all suggestion JSON data here - let suggestionString = "" - let inReasoningPhase = true - let collectedReasoning = "" - - // Initialize variables for streaming JSON parsing - const colors = Array(numSuggestions) - .fill(null) - .map(() => generatePastelColor()) - setStreamingSuggestionColors(colors) - - // Create empty streaming notes with unique IDs - const initialStreamingNotes = Array(numSuggestions) - .fill(null) - .map((_, index) => ({ - id: createId(), - content: "", - color: colors[index] || generatePastelColor(), - isComplete: false, - isScanComplete: false, - })) - setStreamingNotes(initialStreamingNotes) - - // Variables for tracking streaming JSON state - let currentNoteIndex = 0 - let partialJSON = "" - let inJsonSuggestion = false - let currentSuggestion = "" - let isFirstNote = true - - // Pattern variables that account for whitespace - const firstNoteStartPattern = '{"suggestions":\\s*\\[\\s*{"suggestion":\\s*"' - const subsequentNoteStartPattern = '\\s*{"suggestion":\\s*"' - const endPatternWithComma = '"\\s*}\\s*,' - const endPatternFinal = '"\\s*}\\s*]\\s*}' - - // Process the stream - while (true) { - const { value, done } = await reader.read() - if (done) break - - const chunk = decoder.decode(value, { stream: true }) - - // Process each line - for (const line of chunk.split("\n\n")) { - if (!line.trim()) continue - - try { - // Assuming delta structure based on previous code - const delta = JSON.parse(line) as { reasoning?: string; suggestion?: string } - - // Handle reasoning phase - if (delta.reasoning) { - collectedReasoning += delta.reasoning - setStreamingReasoning((prev) => prev + delta.reasoning) - } - - // Check for phase transition - if (delta.reasoning === "" && delta.suggestion !== "") { - // First time we see suggestion means reasoning is complete - if (inReasoningPhase) { - // Record when reasoning phase ended - if (!reasoningEndTime) { - reasoningEndTime = Date.now() - // Calculate and update elapsed time when reasoning ends - const elapsedTime = Math.round((reasoningEndTime - reasoningStartTime) / 1000) - setCurrentReasoningElapsedTime(elapsedTime) - } - setReasoningComplete(true) - inReasoningPhase = false - } - - // Handle each delta suggestion chunk for streaming JSON - // Ensure delta.suggestion is not null/undefined before adding - const suggestionChunk = delta.suggestion || "" - partialJSON += suggestionChunk - - // First note has a different start pattern than subsequent ones - if (!inJsonSuggestion) { - if (isFirstNote) { - // Check for the first note start pattern - const firstStartRegex = new RegExp(firstNoteStartPattern) - const match = firstStartRegex.exec(partialJSON) - - if (match) { - inJsonSuggestion = true - const matchIndex = match.index + match[0].length - currentSuggestion = partialJSON.substring(matchIndex) - partialJSON = "" - isFirstNote = false - } - } else { - // Check for subsequent note start pattern - const subsequentStartRegex = new RegExp(subsequentNoteStartPattern) - const match = subsequentStartRegex.exec(partialJSON) - - if (match) { - inJsonSuggestion = true - const matchIndex = match.index + match[0].length - currentSuggestion = partialJSON.substring(matchIndex) - partialJSON = "" - } - } - } - // If we're inside a suggestion object, accumulate content - else if (inJsonSuggestion) { - currentSuggestion += suggestionChunk // Use the checked suggestionChunk - - // For a better streaming experience, update the note's content with each delta - setStreamingNotes((prevNotes) => { - if (!prevNotes[currentNoteIndex]) return prevNotes - - // Create a new array to avoid mutating the previous state - const updatedNotes = [...prevNotes] - - updatedNotes[currentNoteIndex] = { - ...updatedNotes[currentNoteIndex], - content: sanitizeStreamingContent(currentSuggestion), - } - - return updatedNotes - }) - - // Check for end patterns - either with comma (more notes to come) or final closing pattern - const endWithCommaRegex = new RegExp(endPatternWithComma) - const endFinalRegex = new RegExp(endPatternFinal) - - const endWithCommaMatch = endWithCommaRegex.exec(currentSuggestion) - const endFinalMatch = endFinalRegex.exec(currentSuggestion) - - // Process if we found either ending pattern - if (endWithCommaMatch || endFinalMatch) { - // Get the end index based on which pattern matched - const match = endWithCommaMatch || endFinalMatch - const endIndex = match!.index - - // Extract the suggestion content up to the end pattern - let suggestionContent = currentSuggestion.substring(0, endIndex) - - // Update the streaming note with the current content - setStreamingNotes((prevNotes) => { - if (!prevNotes[currentNoteIndex]) return prevNotes - - // Create a new array to avoid mutating the previous state - const updatedNotes = [...prevNotes] - - updatedNotes[currentNoteIndex] = { - ...updatedNotes[currentNoteIndex], - content: sanitizeStreamingContent(suggestionContent), - isComplete: - endFinalMatch !== null && currentNoteIndex === numSuggestions - 1, - } - - return updatedNotes - }) - - // Reset suggestion tracking - inJsonSuggestion = false - - // Important: Clear both variables to prevent flashing - currentSuggestion = "" - suggestionContent = "" - partialJSON = "" - - // Move to next note if there's more to process - if (endWithCommaMatch) { - currentNoteIndex = Math.min(currentNoteIndex + 1, numSuggestions - 1) - } - } - } - - // Collect suggestion data for final processing - suggestionString += suggestionChunk // Use the checked suggestionChunk - } - } catch (e) { - console.error("Error parsing line:", e) - // Clean up on error to avoid stuck partial content - partialJSON = "" - currentSuggestion = "" - } - } - } - - // Ensure we mark reasoning as complete - if (inReasoningPhase) { - // If we never set the end time but are now complete, set it - if (!reasoningEndTime) { - reasoningEndTime = Date.now() - // Calculate and update elapsed time when reasoning ends - const elapsedTime = Math.round((reasoningEndTime - reasoningStartTime) / 1000) - setCurrentReasoningElapsedTime(elapsedTime) - } - setReasoningComplete(true) - } - - // Mark all streaming notes as complete - setStreamingNotes((prevNotes) => { - const allComplete = prevNotes.map((note) => ({ ...note, isComplete: true })) - return allComplete - }) - - // Calculate elapsed time for reasoning - const reasoningElapsedTime = Math.round((reasoningEndTime! - reasoningStartTime) / 1000) - setCurrentReasoningElapsedTime(reasoningElapsedTime) - - // Run scan animation after a small delay - const runScanAnimation = async () => { - const noteCount = initialStreamingNotes.length - let currentDelay = 100 - for (let i = 0; i < noteCount; i++) { - setStreamingNotes((prevNotes) => { - // Important: Always work with the *latest* state inside the loop - const currentNotes = prevNotes - const updatedNotes = [...currentNotes] - if (updatedNotes[i]) { - updatedNotes[i] = { - ...updatedNotes[i], - isScanComplete: true, - } - } - return updatedNotes - }) - // Gradually reduce the delay for a smooth acceleration effect - currentDelay = Math.max(10, currentDelay * 0.85) - } - // Set loading to false which will trigger showing the final notes - setIsNotesLoading(false) - setScanAnimationComplete(true) - } - - // Start the scan animation sequence - runScanAnimation() - - // At this point we have the complete reasoning, but we don't yet know which notes it produced - // We'll update reasoningHistory later when we know the noteIds - setCurrentReasoningId(reasoningId) - - // Clean up - reader.releaseLock() - - // Parse collected suggestions - let generatedNotes: GeneratedNote[] = [] - - if (suggestionString.trim()) { - try { - const suggestionData: SuggestionResponse = JSON.parse(suggestionString.trim()) - - if (suggestionData.suggestions && Array.isArray(suggestionData.suggestions)) { - generatedNotes = suggestionData.suggestions.map((suggestion) => ({ - content: suggestion.suggestion, - })) - } - } catch (e) { - console.error("Error parsing suggestions:", e) - const errorMsg = "Failed to parse suggestion data" - toast({ - title: "Parsing Error", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - } - } - - if (generatedNotes.length === 0) { - // Set error state when no suggestions could be generated - setNotesError("Could not generate suggestions for this content") - setCurrentlyGeneratingDateKey(null) - } else { - // Save the reasoning content to be associated with the notes later - const reasoningData = { - id: reasoningId, - content: collectedReasoning, - timestamp: new Date(), - noteIds: [], // Will be populated after creating notes - reasoningElapsedTime, - // Add steering parameters if they exist - authors: steeringOptions?.authors, - tonality: steeringOptions?.tonality, - temperature: steeringOptions?.temperature, - numSuggestions: numSuggestions, - } - setReasoningHistory((prev) => [...prev, { ...reasoningData }]) - setNotesError(null) - } - - return { - generatedNotes, - reasoningId, - reasoningElapsedTime, - reasoningContent: collectedReasoning, - } - } catch (error: any) { - // Catch specific error type - const errorMsg = `Notes not available: ${error.message || "Unknown error"}` - setNotesError(errorMsg) - setReasoningComplete(true) - setCurrentlyGeneratingDateKey(null) - - // Only show toast if not already shown by specific error handlers - if ( - !error.message || - (!error.message.includes("Service") && - !error.message.includes("Connection") && - !error.message.includes("Health") && - !error.message.includes("Suggestion") && - !error.message.includes("Empty") && - !error.message.includes("Parsing")) - ) { - toast({ - title: "Error", - description: errorMsg, - variant: "destructive", - duration: 5000, - }) - } - - // Ensure we return a rejected promise - return Promise.reject(error) - } - }, - [toast, droppedNotes], + [db, currentFileId, vault], ) const handleSave = useCallback(async () => { @@ -1487,13 +570,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // Reset all note states for new file setNotes([]) setDroppedNotes([]) - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setReasoningHistory([]) // Reset embedding processing flags - embeddingProcessedRef.current = null essayEmbeddingProcessedRef.current = null setHasUnsavedChanges(false) // Reset unsaved changes @@ -1515,300 +593,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { setIsSettingsOpen((prev) => !prev) }, []) - const generateNewSuggestions = useCallback( - async (steeringSettings: SteeringSettings) => { - if (!vault || !markdownContent) return - - console.log(`[Notes Debug] Starting to generate new suggestions with settings:`, { - numSuggestions: steeringSettings.numSuggestions, - hasAuthors: !!steeringSettings.authors, - temperature: steeringSettings.temperature, - hasTonality: steeringSettings.tonalityEnabled, - }) - - // Move current generation notes to history by adding them to notes array without the currentGenerationNotes flag - if (currentGenerationNotes.length > 0) { - console.log( - `[Notes Debug] Moving ${currentGenerationNotes.length} current generation notes to history`, - ) - // First ensure the current notes are in the main notes array (if they aren't already) - setNotes((prev) => { - // Filter out any notes that might already exist in the array - const notesToAdd = currentGenerationNotes.filter( - (note) => !prev.some((existingNote) => existingNote.id === note.id), - ) - - if (notesToAdd.length === 0) return prev - - console.log(`[Notes Debug] Adding ${notesToAdd.length} notes to main notes array`) - // Add to notes and sort - const combined = [...notesToAdd, ...prev] - return combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - }) - } - - // Clear current generation notes before starting new generation - setCurrentGenerationNotes([]) - setNotesError(null) - // Update loading state via setState - setIsNotesLoading(true) - setStreamingReasoning("") - setReasoningComplete(false) - setStreamingSuggestionColors([]) - setScanAnimationComplete(false) // Reset scan animation state - setLastNotesGeneratedTime(new Date()) - const now = new Date() - const seconds = now.getSeconds() - const interval = Math.floor(seconds / 15) * 15 - const dateKey = `${now.toDateString()}-${now.getHours()}-${now.getMinutes()}-${interval}` - setCurrentlyGeneratingDateKey(dateKey) - - try { - // First, find or create the file in the database to ensure proper ID reference - let dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.id, currentFileId!), eq(files.vaultId, vaultId)), - }) - - console.log(`[Notes Debug] Found file in DB: ${dbFile?.id || "not found"}`) - - if (!dbFile) { - console.log( - `[Notes Debug] File not found in DB, creating new file entry for ${currentFileHandle?.name}`, - ) - // Insert file into database - await db.insert(schema.files).values({ - name: currentFileHandle!.name, - extension: "md", - vaultId: vaultId, - lastModified: new Date(), - embeddingStatus: "in_progress", - }) - - // Re-fetch the file to get its ID - dbFile = await db.query.files.findFirst({ - where: (files, { and, eq }) => - and(eq(files.id, currentFileId!), eq(files.vaultId, vaultId)), - }) - - if (!dbFile) { - throw new Error("Failed to create file in database") - } - console.log(`[Notes Debug] Created new file in DB with ID: ${dbFile.id}`) - } - - try { - // Now that we have the file, generate the suggestions - console.log(`[Notes Debug] Fetching new notes from API`) - const { generatedNotes, reasoningId, reasoningElapsedTime, reasoningContent } = - await fetchNewNotes( - markdownContent, - steeringSettings.numSuggestions, - steeringSettings - ? { - authors: steeringSettings.authors, - tonality: steeringSettings.tonalityEnabled - ? steeringSettings.tonality - : undefined, - temperature: steeringSettings.temperature, - } - : undefined, - ) - - console.log( - `[Notes Debug] Received ${generatedNotes.length} generated notes with reasoning ID: ${reasoningId}`, - ) - - const newNoteIds: string[] = [] - const newNotes: Note[] = generatedNotes.map((note, index) => { - const id = createId() - newNoteIds.push(id) - return { - id, - content: note.content, - color: streamingSuggestionColors[index] || generatePastelColor(), - fileId: dbFile!.id, // Use the actual DB file ID here - vaultId: vaultId, - isInEditor: false, - createdAt: new Date(), - lastModified: new Date(), - reasoningId: reasoningId, - steering: { - authors: steeringSettings.authors, - tonality: steeringSettings.tonalityEnabled ? steeringSettings.tonality : undefined, - temperature: steeringSettings.temperature, - numSuggestions: steeringSettings.numSuggestions, - }, - embeddingStatus: "in_progress", - embeddingTaskId: null, - } - }) - - // Prepare the reasoning record to be saved - const reasoningRecordToSave = { - id: reasoningId, - fileId: dbFile!.id, - vaultId: vaultId, - content: reasoningContent, - noteIds: newNoteIds, - createdAt: now, - accessedAt: now, - duration: reasoningElapsedTime, - steering: { - authors: steeringSettings.authors, - tonality: steeringSettings.tonalityEnabled ? steeringSettings.tonality : undefined, - temperature: steeringSettings.temperature, - numSuggestions: steeringSettings.numSuggestions, - }, - } - - // Only save if the file is not null and we have notes to save - if (currentFileId !== null && vault && newNotes.length > 0) { - try { - console.log(`[Notes Debug] Saving reasoning with ID ${reasoningId} to DB`) - // Use Promise.all to save reasoning and notes concurrently - await Promise.all([ - // Ensure reasoningRecordToSave matches the schema expectations - db.insert(schema.reasonings).values(reasoningRecordToSave), - ...newNotes.map((note) => { - console.log(`[Notes Debug] Saving note ${note.id} to DB`) - return db.insert(schema.notes).values({ - id: note.id, - content: note.content, - color: note.color, - createdAt: note.createdAt, - accessedAt: new Date(), - dropped: note.dropped ?? false, - fileId: dbFile!.id, - vaultId: note.vaultId, - reasoningId: note.reasoningId!, - steering: note.steering || null, - embeddingStatus: "in_progress", - embeddingTaskId: null, - }) - }), - ]) - - console.log(`[Notes Debug] Saving ${newNotes.length} new notes to DB`) - console.log(`[Notes Debug] Notes and reasoning saved successfully.`) - - // **Update UI state AFTER successful DB operations** - // Fix: Ensure the object matches ReasoningHistory type - setReasoningHistory((prev) => [ - ...prev, - { - ...reasoningRecordToSave, - timestamp: now, - reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime - }, - ]) - - setNotes((prev) => { - const combined = [...newNotes, ...prev] - return combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - }) - - // Set current generation notes for UI - setCurrentGenerationNotes(newNotes) - - console.log(`[Notes Debug] Processing embeddings for ${newNotes.length} new notes`) - // Submit each note for embedding individually and track task IDs for polling - for (const note of newNotes) { - console.log(`[Notes Debug] Submitting note ${note.id} for embedding`) - const result = await submitNoteForEmbedding(db, note) - - // If successful and we have a task ID, add it for polling - if (result) { - // Wait a small amount of time to ensure DB updates are complete - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Then get the updated note with task ID - const updatedNote = await db.query.notes.findFirst({ - where: eq(schema.notes.id, note.id), - }) - - if (updatedNote?.embeddingTaskId) { - addTask(updatedNote.embeddingTaskId) - console.log( - `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, - ) - } else { - console.log( - `[Notes Debug] Note ${note.id} has no embedding task ID after submission`, - ) - } - } else { - console.log(`[Notes Debug] Failed to submit note ${note.id} for embedding`) - } - } - - // Log the notes after saving to verify they were saved correctly - await logNotesForFile(db, dbFile!.id, vaultId, "After generating new suggestions") - } catch (dbError) { - console.error("Failed to save notes to database:", dbError) - // Still show notes in UI even if DB fails - console.log(`[Notes Debug] Showing notes in UI despite DB save failure`) - setCurrentGenerationNotes(newNotes) - setNotes((prev) => { - const combined = [...newNotes, ...prev] - return combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - }) - } - } else { - // For unsaved files, just display in UI without saving to DB - console.log(`[Notes Debug] Displaying ephemeral notes for unsaved file`) - setShowEphemeralBanner(true) - setTimeout(() => setShowEphemeralBanner(false), 7000) - // Update UI immediately for ephemeral notes - setReasoningHistory((prev) => [ - ...prev, - { - ...reasoningRecordToSave, - timestamp: now, - reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime - }, - ]) - setCurrentGenerationNotes(newNotes) - setNotes((prev) => { - const combined = [...newNotes, ...prev] - return combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - }) - } - } catch (error: any) { - const errorMessage = - error.message || "Notes not available for this generation, try again later" - setNotesError(errorMessage) - setCurrentlyGeneratingDateKey(null) - console.error(`[Notes Debug] Failed to generate notes: ${errorMessage}`, error) - } finally { - setIsNotesLoading(false) - } - } catch (error) { - console.error("[Notes Debug] Error in generateNewSuggestions:", error) - } - }, - [ - vaultId, - currentFileId, - currentFileHandle, - fetchNewNotes, - vault, - streamingSuggestionColors, - markdownContent, - db, - currentGenerationNotes, - addTask, - ], - ) - // Handle removing a note from the notes array const handleNoteRemoved = useCallback((noteId: string) => { setNotes((prev) => prev.filter((n) => n.id !== noteId)) @@ -1816,7 +600,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { const noteGroupsData = useMemo(() => { // Filter out current generation notes and dropped notes - const currentGenerationNoteIds = new Set(currentGenerationNotes.map((note) => note.id)) + const currentGenerationNoteIds = new Set(notes.map((note) => note.id)) const droppedNoteIds = new Set(droppedNotes.map((note) => note.id)) // Filter out both current generation notes and dropped notes @@ -1826,24 +610,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) return groupNotesByDate(filteredNotes) - }, [notes, currentGenerationNotes, droppedNotes]) - - const handleCurrentGenerationNote = useCallback((note: Note) => { - // Remove from current generation notes - setCurrentGenerationNotes((prev) => prev.filter((n) => n.id !== note.id)) - - // Ensure the note is in the main notes array if it's not already - setNotes((prev) => { - // Check if note already exists in notes array - if (prev.some((existingNote) => existingNote.id === note.id)) return prev - - // Add to notes and sort - const combined = [...prev, note] - return combined.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - }) - }, []) + }, [notes, droppedNotes]) // Memoize dropped notes to prevent unnecessary re-renders const memoizedDroppedNotes = useMemo(() => { @@ -1853,14 +620,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { })) }, [droppedNotes]) - // Check if notes were recently generated (within the last 5 minutes) - // TODO: a small tool calling Qwen to determine if we should recommends users to generate suggestions. - const isNotesRecentlyGenerated = useMemo(() => { - if (!lastNotesGeneratedTime) return false - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes in milliseconds - return lastNotesGeneratedTime > fiveMinutesAgo - }, [lastNotesGeneratedTime]) - // Handle dragging a dropped note back to the panel const handleNoteDragBackToPanel = useCallback( async (noteId: string) => { @@ -1946,13 +705,8 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // This prevents stale notes from appearing temporarily setNotes([]) setDroppedNotes([]) - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setReasoningHistory([]) // Reset embedding processing flags for new file selection - embeddingProcessedRef.current = null essayEmbeddingProcessedRef.current = null const file = await node.handle!.getFile() @@ -1993,17 +747,7 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) if (success) { - console.log( - `[Notes Debug] Successfully loaded content for file ${targetFileId}, now loading metadata`, - ) - - // Load file metadata using the now properly set currentFileId - await loadFileMetadata(targetFileId) - - // Log notes for the file after metadata is loaded - if (vault) { - await logNotesForFile(db, targetFileId, vaultId, "After file selection") - } + console.log(`[Notes Debug] Successfully loaded content for file ${targetFileId}`) console.log( `[Notes Debug] Saving file info with ID: ${targetFileId}, handleId: ${handleId || node.id}`, @@ -2051,18 +795,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { `[Notes Debug] File ${node.name} already has embeddings, skipping processing`, ) } - - // Process any existing notes for this file only once - if (!embeddingProcessedRef.current) { - console.log(`[Notes Debug] Processing note embeddings for ${node.name}`) - embeddingProcessedRef.current = `${vaultId}:${targetFileId}` - processEmbeddings.mutate({ addTask }) - } } catch (error) { console.error("Error processing file data:", error) // Reset flags on error to allow retrying essayEmbeddingProcessedRef.current = null - embeddingProcessedRef.current = null } } } else { @@ -2071,24 +807,16 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } catch (error) { console.error("Error handling file selection:", error) - toast({ - title: "Error Loading File", - description: `Could not load file ${node.name}. Please try again.`, - variant: "destructive", - }) + toast.error(`Could not load file ${node.name}. Please try again.`) } }, [ vault, vaultId, loadFileContent, - loadFileMetadata, - toast, storeHandle, db, addEssayTask, - addTask, - processEmbeddings, processEssayEmbeddings, setRestoredFile, ], @@ -2151,16 +879,9 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { ) setNotes([]) setDroppedNotes([]) - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError(null) - setReasoningHistory([]) - // Reset embedding processing flags - embeddingProcessedRef.current = null - essayEmbeddingProcessedRef.current = null console.log(`[Notes Debug] Loading metadata for restored file: ${currentFileId}`) - await loadFileMetadata(currentFileId) + await loadFileContent(currentFileId, restoredFile.fileHandle, restoredFile.content) console.log(`[Notes Debug] Metadata loaded for ${currentFileId}`) // Log notes after metadata loading attempt @@ -2201,17 +922,10 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { } else { console.log(`[Debug] Restored file ${currentFileId} already has embeddings`) } - - // Process any existing notes for this file (only once) - if (!embeddingProcessedRef.current) { - embeddingProcessedRef.current = `${vaultId}:${currentFileId}` - processEmbeddings.mutate({ addTask }) - } } catch (error) { console.error(`Error processing file data for ${currentFileId}:`, error) // Reset flags on error to allow retrying essayEmbeddingProcessedRef.current = null - embeddingProcessedRef.current = null } }, 1000) // 1 second delay to ensure DB operations are ready } catch (error) { @@ -2219,12 +933,6 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { // If metadata loading fails, ensure the notes state remains cleared setNotes([]) setDroppedNotes([]) - setCurrentGenerationNotes([]) - setCurrentlyGeneratingDateKey(null) - setNotesError( - `Failed to load notes: ${error instanceof Error ? error.message : "Unknown error"}`, - ) - setReasoningHistory([]) } } }, @@ -2241,12 +949,9 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) { vaultId, restoredFile, loadFileContent, - loadFileMetadata, vault, db, - processEmbeddings, processEssayEmbeddings, - addTask, addEssayTask, debouncedUpdatePreview, currentFileId, @@ -2459,21 +1164,18 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) {
{hasUnsavedChanges && }
- {vault && - currentFileId !== null && - memoizedDroppedNotes.length > 0 && - !isNotesLoading && ( - - )} + {vault && currentFileId !== null && memoizedDroppedNotes.length > 0 && ( + + )}
@@ -2507,36 +1209,22 @@ export default memo(function Editor({ vaultId, vaults }: EditorProps) {
{showNotes && currentFileId !== null && ( -
- - - - +
+
)} diff --git a/packages/morph/components/note-panel.tsx b/packages/morph/components/note-panel.tsx index 23460f2..64f9bcc 100644 --- a/packages/morph/components/note-panel.tsx +++ b/packages/morph/components/note-panel.tsx @@ -1,14 +1,28 @@ -import { cn } from "@/lib" +import { cn, sanitizeStreamingContent } from "@/lib" import { generatePastelColor } from "@/lib/notes" import { NOTES_DND_TYPE } from "@/lib/notes" +import { + GeneratedNote, + NewlyGeneratedNotes, + SuggestionRequest, + SuggestionResponse, + checkAgentAvailability, + checkAgentHealth, +} from "@/services/agents" +import { submitNoteForEmbedding, useProcessPendingEmbeddings } from "@/services/notes" +import { createId } from "@paralleldrive/cuid2" import { Cross2Icon, MixerHorizontalIcon, ShadowInnerIcon } from "@radix-ui/react-icons" +import { and, eq, inArray } from "drizzle-orm" +import { drizzle } from "drizzle-orm/pglite" import { AnimatePresence, motion } from "motion/react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useDrop } from "react-dnd" import { Components, Virtuoso } from "react-virtuoso" +import { toast } from "sonner" import { DraggableNoteCard, NoteCard } from "@/components/note-card" import { DateDisplay, NoteGroup } from "@/components/note-group" +import { md } from "@/components/parser" import { ReasoningPanel } from "@/components/reasoning-panel" import { AuthorsSelector, @@ -18,39 +32,26 @@ import { } from "@/components/steering-panel" import { VaultButton } from "@/components/ui/button" -import { useLoading } from "@/context/loading" +import { usePGlite } from "@/context/db" +import { useEmbeddingTasks } from "@/context/embedding" import { SteeringSettings, useSteeringContext } from "@/context/steering" import type { Note } from "@/db/interfaces" +import * as schema from "@/db/schema" interface NotesPanelProps { notes: Note[] - isNotesLoading: boolean - notesError: string | null - currentlyGeneratingDateKey: string | null - currentGenerationNotes: Note[] droppedNotes: Note[] - streamingReasoning: string - reasoningComplete: boolean fileId: string - vaultId?: string - currentReasoningId: string - reasoningHistory: { - id: string - content: string - timestamp: Date - noteIds: string[] - reasoningElapsedTime: number - }[] + vaultId: string handleNoteDropped: (note: Note) => void handleNoteRemoved: (noteId: string) => void - handleCurrentGenerationNote: (note: Note) => void - currentReasoningElapsedTime: number - generateNewSuggestions: (steeringSettings: SteeringSettings) => void noteGroupsData: [string, Note[]][] notesContainerRef: React.RefObject streamingNotes?: StreamingNote[] scanAnimationComplete?: boolean + markdownContent?: string + currentFileHandle?: FileSystemFileHandle | null } export interface StreamingNote { @@ -61,6 +62,18 @@ export interface StreamingNote { isScanComplete?: boolean } +export interface ReasoningHistory { + id: string + content: string + timestamp: Date + noteIds: string[] + reasoningElapsedTime: number + authors?: string[] + tonality?: Record + temperature?: number + numSuggestions?: number +} + const ScrollSeekPlaceholder: Components["ScrollSeekPlaceholder"] = memo( function ScrollSeekPlaceholder({ height }) { // Memoize the skeleton components to prevent re-renders @@ -85,21 +98,118 @@ const ScrollSeekPlaceholder: Components["ScrollSeekPlaceholder"] = memo( }, ) +interface HistoricalNotesProps { + fileId: string + noteGroupsData: [string, Note[]][] + reasoningHistory: ReasoningHistory[] + handleNoteDropped: (note: Note) => void + handleNoteRemoved: (noteId: string) => void + vaultId?: string + notesContainerRef: React.RefObject +} + +// Extract HistoricalNotes into a separate memoized component +const HistoricalNotes = memo( + function HistoricalNotes({ + fileId, + noteGroupsData, + reasoningHistory, + handleNoteDropped, + handleNoteRemoved, + vaultId, + notesContainerRef, + }: HistoricalNotesProps) { + const memoizedNoteSkeletons = useMemo(() => , []) + + const itemContent = useCallback( + (_index: number, group: [string, Note[]]) => { + // Add a safety check for when group is undefined or not properly formed + if (!group || !Array.isArray(group) || group.length < 2) { + return memoizedNoteSkeletons + } + + const [dateStr, dateNotes] = group + + // Only handle historical notes now + const dateReasoning = reasoningHistory.find((r) => + r.noteIds.some((id) => dateNotes.some((note: Note) => note.id === id)), + ) + + return ( +
+ +
+ ) + }, + [ + fileId, + memoizedNoteSkeletons, + vaultId, + handleNoteDropped, + handleNoteRemoved, + reasoningHistory, + ], + ) + + return ( +
+ Math.abs(velocity) > 1000, + exit: (velocity) => Math.abs(velocity) < 100, + }} + customScrollParent={notesContainerRef.current!} + /> +
+ ) + }, + // Custom comparison function for props to prevent unnecessary re-renders + (prevProps, nextProps) => { + // Only re-render if these specific props change + return ( + prevProps.fileId === nextProps.fileId && + prevProps.noteGroupsData === nextProps.noteGroupsData && + prevProps.reasoningHistory === nextProps.reasoningHistory + ) + }, +) + // Create a memoized driver bar component for the notes panel interface DriversBarProps { handleGenerateNewSuggestions: (steeringSettings: SteeringSettings) => void + isNotesRecentlyGenerated: boolean + isNotesLoading: boolean } const DriversBarButtons = memo(function DriversBarButtons({ onToggleSteeringPanel, onGenerateNewSuggestions, + isNotesRecentlyGenerated, + isNotesLoading, }: { onToggleSteeringPanel: () => void onGenerateNewSuggestions: () => void + isNotesRecentlyGenerated: boolean + isNotesLoading: boolean }) { - const { state } = useLoading() - const { isNotesLoading, isNotesRecentlyGenerated } = state - return (
) }) -export const NotesPanel = memo(function NotesPanel({ +// Create a memoized error display component +const ErrorDisplay = memo(function ErrorDisplay({ error }: { error: string | null }) { + if (!error) return null + + return ( +
+ {error} +
+ ) +}) + +const EmptyState = memo(function EmptyState({ notes, - isNotesLoading, - notesError, - currentlyGeneratingDateKey, - currentGenerationNotes, droppedNotes, - streamingReasoning, - reasoningComplete, + isNotesLoading, +}: { + notes: Note[] + droppedNotes: Note[] + isNotesLoading: boolean +}) { + if (isNotesLoading || notes.length !== 0) return null + return ( +
+

+ {droppedNotes.length !== 0 + ? "All notes are currently in the stack." + : "No notes found for this document"} +

+
+ ) +}) + +export const NotesPanel = memo(function NotesPanel({ + notes: initialNotes, + droppedNotes: initialDroppedNotes, vaultId, fileId, - currentReasoningId, - reasoningHistory, handleNoteDropped, handleNoteRemoved, - handleCurrentGenerationNote, - currentReasoningElapsedTime, - generateNewSuggestions, noteGroupsData, notesContainerRef, - streamingNotes, - scanAnimationComplete, + markdownContent: initialMarkdownContent, + currentFileHandle: initialFileHandle, }: NotesPanelProps) { - const memoizedNoteSkeletons = useMemo(() => , []) - // Get steering settings and fileId update function from context const { settings, updateFileId } = useSteeringContext() + const { addTask } = useEmbeddingTasks() + + // State management (moved from editor.tsx) + const [notes, setNotes] = useState(initialNotes || []) + const [notesError, setNotesError] = useState(null) + const [currentlyGeneratingDateKey, setCurrentlyGeneratingDateKey] = useState(null) + const [currentGenerationNotes, setCurrentGenerationNotes] = useState([]) + const [droppedNotes, setDroppedNotes] = useState(initialDroppedNotes || []) + + // Additional state + const [isNotesLoading, setIsNotesLoading] = useState(false) + const [scanAnimationComplete, setScanAnimationComplete] = useState(false) + const [currentReasoningElapsedTime, setCurrentReasoningElapsedTime] = useState(0) + const [lastNotesGeneratedTime, setLastNotesGeneratedTime] = useState(null) + const [currentReasoningId, setCurrentReasoningId] = useState("") + const [streamingSuggestionColors, setStreamingSuggestionColors] = useState([]) + const [streamingNotes, setStreamingNotes] = useState([]) + const [reasoningComplete, setReasoningComplete] = useState(false) + const [streamingReasoning, setStreamingReasoning] = useState("") + const [reasoningHistory, setReasoningHistory] = useState([]) + const [markdownContent, setMarkdownContent] = useState(initialMarkdownContent || "") + const [currentFileHandle, setCurrentFileHandle] = useState( + initialFileHandle || null, + ) + + const embeddingProcessedRef = useRef(null) + // Track settings changes with a ref to detect actual value changes const prevSettingsRef = useRef(settings) const currentFileIdRef = useRef(null) + const client = usePGlite() + const db = useMemo(() => drizzle({ client, schema }), [client]) + + const processEmbeddings = useProcessPendingEmbeddings(db) + + // Load file metadata when fileId changes + const loadFileMetadata = useCallback( + async (fileId: string) => { + if (!vaultId || fileId === null) return + + try { + console.log(`[Notes Debug] Starting to load metadata for file ${fileId}`) + + // Find the file in database + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), + }) + + // If file doesn't exist in DB, log error and return + if (!dbFile) { + console.error(`File ${fileId} does not exist in db. Something has gone very wrong`) + return + } + + // Fetch notes associated with this file in a single query + try { + // Query all notes for this file + const fileNotes = await db + .select() + .from(schema.notes) + .where(and(eq(schema.notes.fileId, dbFile.id), eq(schema.notes.vaultId, vaultId))) + + console.log( + `[Notes Debug] Retrieved ${fileNotes?.length || 0} notes from DB for file ${fileId}`, + ) + + if (fileNotes && fileNotes.length > 0) { + // Process notes and reasoning in parallel + // Separate notes into regular and dropped notes + const regularNotes = fileNotes.filter((note) => !note.dropped) + const droppedNotesList = fileNotes.filter((note) => note.dropped) + + console.log( + `[Notes Debug] Regular notes: ${regularNotes.length}, Dropped notes: ${droppedNotesList.length}`, + ) + + // Prepare notes for the UI + const uiReadyRegularNotes = regularNotes.map((note) => ({ + ...note, + color: note.color || generatePastelColor(), + lastModified: new Date(note.accessedAt), + })) + + const uiReadyDroppedNotes = droppedNotesList.map((note) => ({ + ...note, + color: note.color || generatePastelColor(), + lastModified: new Date(note.accessedAt), + })) + + // Reset note states before updating to avoid merging with previous file's notes + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + + let loadedReasoningHistory: ReasoningHistory[] = [] + + // In parallel, fetch and process reasoning if needed + const reasoningIds = [ + ...new Set(fileNotes.map((note) => note.reasoningId).filter(Boolean)), + ] as string[] // Explicitly type as string[] + + if (reasoningIds.length > 0) { + console.log( + `[Notes Debug] Found ${reasoningIds.length} unique reasoning IDs, fetching reasoning`, + ) + const reasonings = await db + .select() + .from(schema.reasonings) + .where(inArray(schema.reasonings.id, reasoningIds)) + + console.log(`[Notes Debug] Retrieved ${reasonings?.length || 0} reasonings from DB`) + + if (reasonings && reasonings.length > 0) { + // Convert to ReasoningHistory format + loadedReasoningHistory = reasonings.map((r) => ({ + id: r.id, + content: r.content, + timestamp: r.createdAt, + noteIds: fileNotes.filter((n) => n.reasoningId === r.id).map((n) => n.id), + reasoningElapsedTime: r.duration, + authors: r.steering?.authors, + tonality: r.steering?.tonality, + temperature: r.steering?.temperature, + numSuggestions: r.steering?.numSuggestions, + })) + } + } + + // Update the state with fetched notes atomically to prevent flicker + // Ensure we are using the correct variables here + setNotes(uiReadyRegularNotes) + setDroppedNotes(uiReadyDroppedNotes) + setReasoningHistory(loadedReasoningHistory) + + console.log(`[Notes Debug] State updated after loading metadata:`, { + notesCount: uiReadyRegularNotes.length, + droppedNotesCount: uiReadyDroppedNotes.length, + reasoningHistoryCount: loadedReasoningHistory.length, + }) + } else { + console.log(`[Notes Debug] No notes found for file ${fileId}, resetting state`) + // Ensure state is reset even if no notes are found + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) + setCurrentGenerationNotes([]) + setCurrentlyGeneratingDateKey(null) + setNotesError(null) + } + + // Process embeddings if needed + if (fileNotes && fileNotes.length > 0 && !embeddingProcessedRef.current) { + console.log(`[Notes Debug] Processing embeddings for ${fileNotes.length} notes`) + processEmbeddings.mutate({ addTask }) + embeddingProcessedRef.current = `${vaultId}:${fileId}` + } + } catch (error) { + console.error("Error fetching notes for file:", error) + // Reset note states on error + setCurrentGenerationNotes([]) + setNotesError( + `Failed to load notes: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + setNotes([]) + setDroppedNotes([]) + setReasoningHistory([]) + } + } catch (dbError) { + console.error("Error with database operations:", dbError) + setNotesError( + `Database error: ${dbError instanceof Error ? dbError.message : "Unknown error"}`, + ) + } + }, + [db, vaultId, processEmbeddings, addTask], + ) + + // Load file metadata when fileId changes + // useEffect(() => { + // if (fileId) { + // loadFileMetadata(fileId) + // currentFileIdRef.current = fileId + // } else { + // // Reset state when fileId is null + // setNotes([]) + // setDroppedNotes([]) + // setReasoningHistory([]) + // setCurrentGenerationNotes([]) + // setCurrentlyGeneratingDateKey(null) + // setNotesError(null) + // embeddingProcessedRef.current = null + // } + // }, [fileId, loadFileMetadata]) + + // When unmounting or when fileId changes, save any pending notes to DB + useEffect(() => { + return () => { + // Save current generation notes to DB when unmounting + if (currentGenerationNotes.length > 0 && currentlyGeneratingDateKey && fileId) { + console.log( + `[Notes Debug] Saving ${currentGenerationNotes.length} current generation notes to DB on unmount`, + ) + + // We can't await in useEffect cleanup, so we use a fire-and-forget approach + // This is okay for cleanup operations + const saveNotes = async () => { + try { + // Find the file in database + const dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => + and(eq(files.id, fileId), eq(files.vaultId, vaultId || "")), + }) + + if (!dbFile) { + console.error("Failed to find file in database when saving notes on unmount") + return + } + + // For each note, ensure it's saved to database if not already + for (const note of currentGenerationNotes) { + const existingNote = await db.query.notes.findFirst({ + where: eq(schema.notes.id, note.id), + }) + + if (!existingNote) { + console.log(`[Notes Debug] Saving note ${note.id} to DB on unmount`) + + await db.insert(schema.notes).values({ + id: note.id, + content: note.content, + color: note.color, + createdAt: note.createdAt, + accessedAt: new Date(), + dropped: note.dropped ?? false, + fileId: dbFile.id, + vaultId: note.vaultId, + reasoningId: note.reasoningId || null, + steering: note.steering || null, + embeddingStatus: "in_progress", + embeddingTaskId: null, + }) + } + } + } catch (error) { + console.error("Failed to save notes to database on unmount:", error) + } + } + + saveNotes() + } + } + }, [currentGenerationNotes, currentlyGeneratingDateKey, fileId, vaultId, db]) + + // Update markdownContent and vault state with props + useEffect(() => { + if (initialMarkdownContent) { + setMarkdownContent(initialMarkdownContent) + } + if (initialFileHandle) { + setCurrentFileHandle(initialFileHandle) + } + }, [initialMarkdownContent, initialFileHandle]) + + const fetchNewNotes = useCallback( + async ( + content: string, + numSuggestions: number, + steeringOptions?: { + authors?: string[] + tonality?: Record + temperature?: number + }, + ): Promise => { + try { + const apiEndpoint = process.env.NEXT_PUBLIC_API_ENDPOINT || "http://localhost:8000" + + const isAgentAvailable = await checkAgentAvailability() + if (!isAgentAvailable) { + throw new Error("Agent service is not available. Please try again later.") + } + + // Check agent health to ensure it's functioning properly + const healthStatus = await checkAgentHealth() + if (!healthStatus.healthy) { + const unhealthyServices = healthStatus.services + .filter((service) => !service.healthy) + .map((service) => `${service.name} (${service.error || "Unknown error"})`) + .join(", ") + + console.error("Service health check failed:", { + timestamp: healthStatus.timestamp, + overallHealth: healthStatus.healthy, + unhealthyServices, + allServices: healthStatus.services, + }) + toast(`Services unavailable: ${unhealthyServices}`) + } + + // Create a new reasoning ID for this generation + const reasoningId = createId() + + // Reset states for new generation + setStreamingReasoning("") + setReasoningComplete(false) + setCurrentReasoningElapsedTime(0) // Reset elapsed time at the start + setStreamingNotes([]) // Reset streaming notes + setScanAnimationComplete(false) // Reset scan animation state + + // Set a current date key for the new notes group with 15-second interval + const now = new Date() + const seconds = now.getSeconds() + const interval = Math.floor(seconds / 15) * 15 + const dateKey = `${now.toDateString()}-${now.getHours()}-${now.getMinutes()}-${interval}` + setCurrentlyGeneratingDateKey(dateKey) + + const max_tokens = 16384 + const essay = md(content).content + const request: SuggestionRequest = { + essay, + num_suggestions: numSuggestions, + temperature: steeringOptions?.temperature ?? 0.6, + max_tokens, + ...(steeringOptions?.authors && { authors: steeringOptions.authors }), + ...(steeringOptions?.tonality && { tonality: steeringOptions.tonality }), + usage: true, + } + + // Add dropped notes to the request if there are any + if (droppedNotes.length > 0) { + request.notes = droppedNotes.map((note) => ({ + vault_id: note.vaultId, + file_id: note.fileId, + note_id: note.id, + content: note.content, + })) + } + + // Start timing reasoning phase + const reasoningStartTime = Date.now() + let reasoningEndTime: number | null = null + + // Create streaming request + let response: Response + try { + response = await fetch(`${apiEndpoint}/suggests`, { + method: "POST", + headers: { Accept: "text/event-stream", "Content-Type": "application/json" }, + body: JSON.stringify(request), + }) + + if (!response.ok) { + const errorMsg = `Failed to fetch suggestions: ${response.statusText || "Unknown error"}` + toast.error(errorMsg) + throw new Error(errorMsg) + } + + if (!response.body) { + const errorMsg = "Response body is empty" + toast.error(errorMsg) + throw new Error(errorMsg) + } + } catch (suggestError: any) { + const errorMsg = `Failed to get suggestions: ${suggestError.message || "Unknown error"}` + toast.error(errorMsg) + throw new Error(errorMsg) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + // We'll collect all suggestion JSON data here + let suggestionString = "" + let inReasoningPhase = true + let collectedReasoning = "" + + // Initialize variables for streaming JSON parsing + const colors = Array(numSuggestions) + .fill(null) + .map(() => generatePastelColor()) + setStreamingSuggestionColors(colors) + + // Create empty streaming notes with unique IDs + const initialStreamingNotes = Array(numSuggestions) + .fill(null) + .map((_, index) => ({ + id: createId(), + content: "", + color: colors[index] || generatePastelColor(), + isComplete: false, + isScanComplete: false, + })) + setStreamingNotes(initialStreamingNotes) + + // Variables for tracking streaming JSON state + let currentNoteIndex = 0 + let partialJSON = "" + let inJsonSuggestion = false + let currentSuggestion = "" + let isFirstNote = true + + // Pattern variables that account for whitespace + const firstNoteStartPattern = '{"suggestions":\\s*\\[\\s*{"suggestion":\\s*"' + const subsequentNoteStartPattern = '\\s*{"suggestion":\\s*"' + const endPatternWithComma = '"\\s*}\\s*,' + const endPatternFinal = '"\\s*}\\s*]\\s*}' + + // Process the stream + while (true) { + const { value, done } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + + // Process each line + for (const line of chunk.split("\n\n")) { + if (!line.trim()) continue + + try { + // Assuming delta structure based on previous code + const delta = JSON.parse(line) as { reasoning?: string; suggestion?: string } + + // Handle reasoning phase + if (delta.reasoning) { + collectedReasoning += delta.reasoning + setStreamingReasoning((prev) => prev + delta.reasoning) + } + + // Check for phase transition + if (delta.reasoning === "" && delta.suggestion !== "") { + // First time we see suggestion means reasoning is complete + if (inReasoningPhase) { + // Record when reasoning phase ended + if (!reasoningEndTime) { + reasoningEndTime = Date.now() + // Calculate and update elapsed time when reasoning ends + const elapsedTime = Math.round((reasoningEndTime - reasoningStartTime) / 1000) + setCurrentReasoningElapsedTime(elapsedTime) + } + setReasoningComplete(true) + inReasoningPhase = false + } + + // Handle each delta suggestion chunk for streaming JSON + // Ensure delta.suggestion is not null/undefined before adding + const suggestionChunk = delta.suggestion || "" + partialJSON += suggestionChunk + + // First note has a different start pattern than subsequent ones + if (!inJsonSuggestion) { + if (isFirstNote) { + // Check for the first note start pattern + const firstStartRegex = new RegExp(firstNoteStartPattern) + const match = firstStartRegex.exec(partialJSON) + + if (match) { + inJsonSuggestion = true + const matchIndex = match.index + match[0].length + currentSuggestion = partialJSON.substring(matchIndex) + partialJSON = "" + isFirstNote = false + } + } else { + // Check for subsequent note start pattern + const subsequentStartRegex = new RegExp(subsequentNoteStartPattern) + const match = subsequentStartRegex.exec(partialJSON) + + if (match) { + inJsonSuggestion = true + const matchIndex = match.index + match[0].length + currentSuggestion = partialJSON.substring(matchIndex) + partialJSON = "" + } + } + } + // If we're inside a suggestion object, accumulate content + else if (inJsonSuggestion) { + currentSuggestion += suggestionChunk // Use the checked suggestionChunk + + // For a better streaming experience, update the note's content with each delta + setStreamingNotes((prevNotes) => { + if (!prevNotes[currentNoteIndex]) return prevNotes + + // Create a new array to avoid mutating the previous state + const updatedNotes = [...prevNotes] + + updatedNotes[currentNoteIndex] = { + ...updatedNotes[currentNoteIndex], + content: sanitizeStreamingContent(currentSuggestion), + } + + return updatedNotes + }) + + // Check for end patterns - either with comma (more notes to come) or final closing pattern + const endWithCommaRegex = new RegExp(endPatternWithComma) + const endFinalRegex = new RegExp(endPatternFinal) + + const endWithCommaMatch = endWithCommaRegex.exec(currentSuggestion) + const endFinalMatch = endFinalRegex.exec(currentSuggestion) + + // Process if we found either ending pattern + if (endWithCommaMatch || endFinalMatch) { + // Get the end index based on which pattern matched + const match = endWithCommaMatch || endFinalMatch + const endIndex = match!.index + + // Extract the suggestion content up to the end pattern + let suggestionContent = currentSuggestion.substring(0, endIndex) + + // Update the streaming note with the current content + setStreamingNotes((prevNotes) => { + if (!prevNotes[currentNoteIndex]) return prevNotes + + // Create a new array to avoid mutating the previous state + const updatedNotes = [...prevNotes] + + updatedNotes[currentNoteIndex] = { + ...updatedNotes[currentNoteIndex], + content: sanitizeStreamingContent(suggestionContent), + isComplete: + endFinalMatch !== null && currentNoteIndex === numSuggestions - 1, + } + + return updatedNotes + }) + + // Reset suggestion tracking + inJsonSuggestion = false + + // Important: Clear both variables to prevent flashing + currentSuggestion = "" + suggestionContent = "" + partialJSON = "" + + // Move to next note if there's more to process + if (endWithCommaMatch) { + currentNoteIndex = Math.min(currentNoteIndex + 1, numSuggestions - 1) + } + } + } + + // Collect suggestion data for final processing + suggestionString += suggestionChunk // Use the checked suggestionChunk + } + } catch (e) { + console.error("Error parsing line:", e) + // Clean up on error to avoid stuck partial content + partialJSON = "" + currentSuggestion = "" + } + } + } + + // Ensure we mark reasoning as complete + if (inReasoningPhase) { + // If we never set the end time but are now complete, set it + if (!reasoningEndTime) { + reasoningEndTime = Date.now() + // Calculate and update elapsed time when reasoning ends + const elapsedTime = Math.round((reasoningEndTime - reasoningStartTime) / 1000) + setCurrentReasoningElapsedTime(elapsedTime) + } + setReasoningComplete(true) + } + + // Mark all streaming notes as complete + setStreamingNotes((prevNotes) => { + const allComplete = prevNotes.map((note) => ({ ...note, isComplete: true })) + return allComplete + }) + + // Calculate elapsed time for reasoning + const reasoningElapsedTime = Math.round((reasoningEndTime! - reasoningStartTime) / 1000) + setCurrentReasoningElapsedTime(reasoningElapsedTime) + + // Run scan animation after a small delay + const runScanAnimation = async () => { + const noteCount = initialStreamingNotes.length + let currentDelay = 100 + for (let i = 0; i < noteCount; i++) { + setStreamingNotes((prevNotes) => { + // Important: Always work with the *latest* state inside the loop + const currentNotes = prevNotes + const updatedNotes = [...currentNotes] + if (updatedNotes[i]) { + updatedNotes[i] = { + ...updatedNotes[i], + isScanComplete: true, + } + } + return updatedNotes + }) + // Gradually reduce the delay for a smooth acceleration effect + currentDelay = Math.max(10, currentDelay * 0.85) + } + // Set loading to false which will trigger showing the final notes + setIsNotesLoading(false) + setScanAnimationComplete(true) + } + + // Start the scan animation sequence + runScanAnimation() + + // At this point we have the complete reasoning, but we don't yet know which notes it produced + // We'll update reasoningHistory later when we know the noteIds + setCurrentReasoningId(reasoningId) + + // Clean up + reader.releaseLock() + + // Parse collected suggestions + let generatedNotes: GeneratedNote[] = [] + + if (suggestionString.trim()) { + try { + const suggestionData: SuggestionResponse = JSON.parse(suggestionString.trim()) + + if (suggestionData.suggestions && Array.isArray(suggestionData.suggestions)) { + generatedNotes = suggestionData.suggestions.map((suggestion) => ({ + content: suggestion.suggestion, + })) + } + } catch (e) { + console.error("Error parsing suggestions:", e) + toast.error("Failed to parse suggestion data") + } + } + + if (generatedNotes.length === 0) { + // Set error state when no suggestions could be generated + setNotesError("Could not generate suggestions for this content") + setCurrentlyGeneratingDateKey(null) + } else { + // Save the reasoning content to be associated with the notes later + const reasoningData = { + id: reasoningId, + content: collectedReasoning, + timestamp: new Date(), + noteIds: [], // Will be populated after creating notes + reasoningElapsedTime, + // Add steering parameters if they exist + authors: steeringOptions?.authors, + tonality: steeringOptions?.tonality, + temperature: steeringOptions?.temperature, + numSuggestions: numSuggestions, + } + setReasoningHistory((prev) => [...prev, { ...reasoningData }]) + setNotesError(null) + } + + return { + generatedNotes, + reasoningId, + reasoningElapsedTime, + reasoningContent: collectedReasoning, + } + } catch (error: any) { + // Catch specific error type + const errorMsg = `Notes not available: ${error.message || "Unknown error"}` + setNotesError(errorMsg) + setReasoningComplete(true) + setCurrentlyGeneratingDateKey(null) + + // Ensure we return a rejected promise + return Promise.reject(error) + } + }, + [toast, droppedNotes], + ) + + // Check if notes were recently generated (within the last 5 minutes) + const isNotesRecentlyGenerated = useMemo(() => { + if (!lastNotesGeneratedTime) return false + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes in milliseconds + return lastNotesGeneratedTime > fiveMinutesAgo + }, [lastNotesGeneratedTime]) + + // Fix typing in the generateNewSuggestions function + const generateNewSuggestions = useCallback( + async (steeringSettings: SteeringSettings) => { + if (!vaultId || !fileId || !markdownContent) { + console.log("[Notes Debug] Cannot generate suggestions - missing vault, file or content") + return + } + + console.log(`[Notes Debug] Starting to generate new suggestions with settings:`, { + numSuggestions: steeringSettings.numSuggestions, + hasAuthors: !!steeringSettings.authors, + temperature: steeringSettings.temperature, + hasTonality: steeringSettings.tonalityEnabled, + }) + + // Move current generation notes to history by adding them to notes array + if (currentGenerationNotes.length > 0) { + console.log( + `[Notes Debug] Moving ${currentGenerationNotes.length} current generation notes to history`, + ) + // First ensure the current notes are in the main notes array (if they aren't already) + setNotes((prev: Note[]) => { + // Filter out any notes that might already exist in the array + const notesToAdd = currentGenerationNotes.filter( + (note) => !prev.some((existingNote: Note) => existingNote.id === note.id), + ) + + if (notesToAdd.length === 0) return prev + + console.log(`[Notes Debug] Adding ${notesToAdd.length} notes to main notes array`) + // Add to notes and sort + const combined = [...notesToAdd, ...prev] + return combined.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + }) + } + + // Clear current generation notes before starting new generation + setCurrentGenerationNotes([]) + setNotesError(null) + // Update loading state via setState + setIsNotesLoading(true) + setStreamingReasoning("") + setReasoningComplete(false) + setStreamingSuggestionColors([]) + setScanAnimationComplete(false) // Reset scan animation state + setLastNotesGeneratedTime(new Date()) + const now = new Date() + const seconds = now.getSeconds() + const interval = Math.floor(seconds / 15) * 15 + const dateKey = `${now.toDateString()}-${now.getHours()}-${now.getMinutes()}-${interval}` + setCurrentlyGeneratingDateKey(dateKey) + + try { + // First, find or create the file in the database to ensure proper ID reference + let dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), + }) + + console.log(`[Notes Debug] Found file in DB: ${dbFile?.id || "not found"}`) + + if (!dbFile) { + if (!currentFileHandle) { + console.error("[Notes Debug] No file handle available to create DB entry") + throw new Error("No file handle available") + } + + console.log( + `[Notes Debug] File not found in DB, creating new file entry for ${currentFileHandle.name}`, + ) + // Insert file into database + await db.insert(schema.files).values({ + id: fileId, + name: currentFileHandle.name, + extension: "md", + vaultId: vaultId, + lastModified: new Date(), + embeddingStatus: "in_progress", + }) + + // Re-fetch the file to get its ID + dbFile = await db.query.files.findFirst({ + where: (files, { and, eq }) => and(eq(files.id, fileId), eq(files.vaultId, vaultId)), + }) + + if (!dbFile) { + throw new Error("Failed to create file in database") + } + console.log(`[Notes Debug] Created new file in DB with ID: ${dbFile.id}`) + } + + try { + // Now that we have the file, generate the suggestions + console.log(`[Notes Debug] Fetching new notes from API`) + const { generatedNotes, reasoningId, reasoningElapsedTime, reasoningContent } = + await fetchNewNotes( + markdownContent, + steeringSettings.numSuggestions, + steeringSettings + ? { + authors: steeringSettings.authors, + tonality: steeringSettings.tonalityEnabled + ? steeringSettings.tonality + : undefined, + temperature: steeringSettings.temperature, + } + : undefined, + ) + + console.log( + `[Notes Debug] Received ${generatedNotes.length} generated notes with reasoning ID: ${reasoningId}`, + ) + + const newNoteIds: string[] = [] + const newNotes = generatedNotes.map((note, index) => { + const id = createId() + newNoteIds.push(id) + return { + id, + content: note.content, + color: streamingSuggestionColors[index] || generatePastelColor(), + fileId: dbFile!.id, // Use the actual DB file ID here + vaultId, + isInEditor: false, + createdAt: new Date(), + lastModified: new Date(), + reasoningId: reasoningId, + steering: { + authors: steeringSettings.authors, + tonality: steeringSettings.tonalityEnabled ? steeringSettings.tonality : undefined, + temperature: steeringSettings.temperature, + numSuggestions: steeringSettings.numSuggestions, + }, + embeddingStatus: "in_progress", + embeddingTaskId: null, + } as Note + }) + + // Prepare the reasoning record to be saved + const reasoningRecordToSave = { + id: reasoningId, + fileId: dbFile!.id, + vaultId, + content: reasoningContent, + noteIds: newNoteIds, + createdAt: now, + accessedAt: now, + duration: reasoningElapsedTime, + steering: { + authors: steeringSettings.authors, + tonality: steeringSettings.tonalityEnabled ? steeringSettings.tonality : undefined, + temperature: steeringSettings.temperature, + numSuggestions: steeringSettings.numSuggestions, + }, + } + + // Only save if the file is not null and we have notes to save + if (fileId && newNotes.length > 0) { + try { + console.log(`[Notes Debug] Saving reasoning with ID ${reasoningId} to DB`) + // Use Promise.all to save reasoning and notes concurrently + await Promise.all([ + // Ensure reasoningRecordToSave matches the schema expectations + db.insert(schema.reasonings).values(reasoningRecordToSave), + ...newNotes.map((note) => { + console.log(`[Notes Debug] Saving note ${note.id} to DB`) + return db.insert(schema.notes).values({ + id: note.id, + content: note.content, + color: note.color, + createdAt: note.createdAt, + accessedAt: new Date(), + dropped: note.dropped ?? false, + fileId: dbFile!.id, + vaultId: note.vaultId, + reasoningId: note.reasoningId!, + steering: note.steering || null, + embeddingStatus: "in_progress", + embeddingTaskId: null, + }) + }), + ]) + + console.log(`[Notes Debug] Saving ${newNotes.length} new notes to DB`) + console.log(`[Notes Debug] Notes and reasoning saved successfully.`) + + // **Update UI state AFTER successful DB operations** + // Fix: Ensure the object matches ReasoningHistory type + setReasoningHistory((prev) => [ + ...prev, + { + ...reasoningRecordToSave, + timestamp: now, + reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime + }, + ]) + + setNotes((prev: Note[]) => { + const combined = [...newNotes, ...prev] + return combined.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + }) + + // Set current generation notes for UI + setCurrentGenerationNotes(newNotes) + + console.log(`[Notes Debug] Processing embeddings for ${newNotes.length} new notes`) + // Submit each note for embedding individually and track task IDs for polling + for (const note of newNotes) { + console.log(`[Notes Debug] Submitting note ${note.id} for embedding`) + const result = await submitNoteForEmbedding(db, note) + + // If successful and we have a task ID, add it for polling + if (result) { + // Wait a small amount of time to ensure DB updates are complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Then get the updated note with task ID + const updatedNote = await db.query.notes.findFirst({ + where: eq(schema.notes.id, note.id), + }) + + if (updatedNote?.embeddingTaskId) { + addTask(updatedNote.embeddingTaskId) + console.log( + `[Notes Debug] Added embedding task ${updatedNote.embeddingTaskId} for note ${note.id}`, + ) + } else { + console.log( + `[Notes Debug] Note ${note.id} has no embedding task ID after submission`, + ) + } + } else { + console.log(`[Notes Debug] Failed to submit note ${note.id} for embedding`) + } + } + + // Log the notes after saving to verify they were saved correctly + await logNotesForFile(db, dbFile!.id, vaultId, "After generating new suggestions") + } catch (dbError) { + console.error("Failed to save notes to database:", dbError) + // Still show notes in UI even if DB fails + console.log(`[Notes Debug] Showing notes in UI despite DB save failure`) + setCurrentGenerationNotes(newNotes) + setNotes((prev: Note[]) => { + const combined = [...newNotes, ...prev] + return combined.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + }) + } + } else { + // For unsaved files, just display in UI without saving to DB + console.log(`[Notes Debug] Displaying ephemeral notes for unsaved file`) + // Update UI immediately for ephemeral notes + setReasoningHistory((prev) => [ + ...prev, + { + ...reasoningRecordToSave, + timestamp: now, + reasoningElapsedTime: reasoningRecordToSave.duration, // Use duration for elapsedTime + }, + ]) + setCurrentGenerationNotes(newNotes) + setNotes((prev: Note[]) => { + const combined = [...newNotes, ...prev] + return combined.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + }) + } + } catch (error: any) { + const errorMessage = + error.message || "Notes not available for this generation, try again later" + setNotesError(errorMessage) + setCurrentlyGeneratingDateKey(null) + console.error(`[Notes Debug] Failed to generate notes: ${errorMessage}`, error) + } finally { + setIsNotesLoading(false) + } + } catch (error) { + console.error("[Notes Debug] Error in generateNewSuggestions:", error) + } + }, + [ + vaultId, + fileId, + fetchNewNotes, + markdownContent, + db, + currentGenerationNotes, + addTask, + streamingSuggestionColors, + currentFileHandle, + ], + ) + // Add effect to properly track settings changes useEffect(() => { if (JSON.stringify(prevSettingsRef.current) !== JSON.stringify(settings)) { @@ -323,68 +1429,22 @@ export const NotesPanel = memo(function NotesPanel({ [drop], ) - const itemContent = useCallback( - (_index: number, group: [string, Note[]]) => { - // Add a safety check for when group is undefined or not properly formed - if (!group || !Array.isArray(group) || group.length < 2) { - return memoizedNoteSkeletons - } + const handleCurrentGenerationNote = useCallback((note: Note) => { + // Remove from current generation notes + setCurrentGenerationNotes((prev) => prev.filter((n) => n.id !== note.id)) - const [dateStr, dateNotes] = group + // Ensure the note is in the main notes array if it's not already + setNotes((prev) => { + // Check if note already exists in notes array + if (prev.some((existingNote) => existingNote.id === note.id)) return prev - // Only handle historical notes now - const dateReasoning = reasoningHistory.find((r) => - r.noteIds.some((id) => dateNotes.some((note: Note) => note.id === id)), - ) - - return ( -
- -
+ // Add to notes and sort + const combined = [...prev, note] + return combined.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ) - }, - [ - fileId, - memoizedNoteSkeletons, - vaultId, - handleNoteDropped, - handleNoteRemoved, - reasoningHistory, - ], - ) - - const memoizedVirtuoso = useMemo( - () => ( -
- Math.abs(velocity) > 1000, - exit: (velocity) => Math.abs(velocity) < 100, - }} - customScrollParent={notesContainerRef.current!} - /> -
- ), - [fileId, noteGroupsData, itemContent, notesContainerRef], - ) + }) + }, []) return (
-
- {!isNotesLoading && notes.length === 0 ? ( -
-

- {droppedNotes.length !== 0 - ? "All notes are currently in the stack." - : "No notes found for this document"} -

-
- ) : ( -
-
- {notesError && ( -
- {notesError} -
- )} - {/* Only show current generation section if there are active notes in it */} - {!notesError && - currentlyGeneratingDateKey && - (isNotesLoading || - (!scanAnimationComplete && reasoningComplete) || - (scanAnimationComplete && +
+ +
+
+ + {/* Only show current generation section if there are active notes in it */} + {!notesError && + currentlyGeneratingDateKey && + (isNotesLoading || + (!scanAnimationComplete && reasoningComplete) || + (scanAnimationComplete && + currentGenerationNotes.length > 0 && + !droppedNotes.some((d) => d.id === currentGenerationNotes[0]?.id))) && ( +
+
+ + + 0} + elapsedTime={currentReasoningElapsedTime} + /> +
+ + {/* Show streaming notes during generation phase */} + {reasoningComplete && + !scanAnimationComplete && + streamingNotes && + streamingNotes.length > 0 && ( +
+ {streamingNotes.map((note) => ( + + ))} +
+ )} + + {/* Show actual generated notes when complete */} + {!isNotesLoading && + scanAnimationComplete && currentGenerationNotes.length > 0 && - !droppedNotes.some((d) => d.id === currentGenerationNotes[0]?.id))) && ( -
-
- - - 0} - elapsedTime={currentReasoningElapsedTime} - /> -
- - {/* Show streaming notes during generation phase */} - {reasoningComplete && - !scanAnimationComplete && - streamingNotes && - streamingNotes.length > 0 && ( -
- {streamingNotes.map((note) => ( - + + {currentGenerationNotes.map((note) => ( + ))} -
- )} - - {/* Show actual generated notes when complete */} - {!isNotesLoading && - scanAnimationComplete && - currentGenerationNotes.length > 0 && - !notesError && ( - - - {currentGenerationNotes.map((note) => ( - - ))} - - - )} -
- )} - {memoizedVirtuoso} -
+ + + )} +
+ )} + +
- )} +
- +
) }) + +// Function to log notes for a file (utility function) +async function logNotesForFile( + db: any, + fileId: string, + vaultId: string, + label: string = "Notes query", +) { + if (process.env.NODE_ENV !== "development") return + + try { + console.log(`[Notes Debug] ${label} - Querying all notes for file ${fileId}`) + + // Query all notes for this file + const fileNotes = await db + .select() + .from(schema.notes) + .where(and(eq(schema.notes.fileId, fileId), eq(schema.notes.vaultId, vaultId))) + + const regularNotes = fileNotes.filter((note: any) => !note.dropped) + const droppedNotes = fileNotes.filter((note: any) => note.dropped) + + console.log(`[Notes Debug] ${label} - Found ${fileNotes.length} total notes:`) + console.log( + `[Notes Debug] ${label} - Regular notes: ${regularNotes.length}, Dropped notes: ${droppedNotes.length}`, + ) + + // Query all reasonings associated with these notes + const reasoningIds = [ + ...new Set(fileNotes.map((note: any) => note.reasoningId).filter(Boolean)), + ] as string[] // Explicitly type as string[] + + if (reasoningIds.length > 0) { + console.log(`[Notes Debug] ${label} - Found ${reasoningIds.length} unique reasoning IDs`) + + const reasonings = await db + .select() + .from(schema.reasonings) + .where(inArray(schema.reasonings.id, reasoningIds)) + + console.log( + `[Notes Debug] ${label} - Retrieved ${reasonings?.length || 0} reasonings from DB`, + ) + + // Log a summary of each reasoning and its notes + for (const reasoning of reasonings) { + const notesForReasoning = fileNotes.filter((n: any) => n.reasoningId === reasoning.id) + console.log( + `[Notes Debug] ${label} - Reasoning ${reasoning.id} has ${notesForReasoning.length} notes:`, + notesForReasoning.map((n: any) => ({ + id: n.id, + dropped: n.dropped, + embeddingStatus: n.embeddingStatus, + })), + ) + } + } else { + console.log(`[Notes Debug] ${label} - No reasoning IDs found`) + } + + return { fileNotes, regularNotes, droppedNotes } + } catch (error) { + console.error(`[Notes Debug] ${label} - Error querying notes:`, error) + return { fileNotes: [], regularNotes: [], droppedNotes: [] } + } +} diff --git a/packages/morph/components/rails.tsx b/packages/morph/components/rails.tsx index e26991e..785eba8 100644 --- a/packages/morph/components/rails.tsx +++ b/packages/morph/components/rails.tsx @@ -18,6 +18,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { useRouter } from "next/navigation" import * as React from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toast } from "sonner" import { setFile } from "@/components/markdown-inline" import { VaultButton } from "@/components/ui/button" @@ -34,7 +35,6 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarMenuSub } from "@/components import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import usePersistedSettings from "@/hooks/use-persisted-settings" -import { useToast } from "@/hooks/use-toast" import { FileSystemTreeNode, Vault } from "@/db/interfaces" @@ -152,7 +152,6 @@ export const FileTreeNode = memo( node, nodePath = "", }: Omit & { nodePath?: string }) { - const { toast } = useToast() const { onFileSelect, isExpanded } = React.useContext(FileTreeContext) const { expandedFolders, setExpandedFolder } = React.useContext(ExpandedFoldersContext) @@ -205,13 +204,9 @@ export const FileTreeNode = memo( }, 1000) } catch (error) { console.error("Error opening file:", error) - toast({ - title: "Error opening file", - description: "Could not open the file with the system's default application", - variant: "destructive", - }) + toast.error("Could not open the file with the system's default application") } - }, [node, toast]) + }, [node]) const MemoizedSidebarFolderItem = useMemo( () => ( @@ -727,7 +722,6 @@ export default memo(function Rails({ onContentUpdate, setIsSettingsOpen, }: RailsProps) { - const { toast } = useToast() const { toggleSidebar, state } = useSidebar() const isExpanded = state === "expanded" const router = useRouter() @@ -776,7 +770,7 @@ export default memo(function Rails({ async (node: FileSystemTreeNode) => { if (!node || node.kind !== "file" || node.extension !== "md") { if (node && node.kind === "file" && node.extension !== "md") { - toast({ title: "File picker", description: "Can only open markdown files" }) + toast.warning("Can only open markdown files") } return } @@ -811,7 +805,7 @@ export default memo(function Rails({ console.error("Error reading file:", error) } }, - [vault, editorViewRef, onContentUpdate, onFileSelect, toast], + [vault, editorViewRef, onContentUpdate, onFileSelect], ) // Memoize the motion components to prevent unnecessary recalculations diff --git a/packages/morph/components/search-command.tsx b/packages/morph/components/search-command.tsx index 6f47d78..323cf04 100644 --- a/packages/morph/components/search-command.tsx +++ b/packages/morph/components/search-command.tsx @@ -2,6 +2,7 @@ import { highlight, slugifyFilePath, toJsx } from "@/lib" import { CommandGroup } from "cmdk" import { fromHtmlIsomorphic } from "hast-util-from-html-isomorphic" import { useCallback, useEffect, useMemo, useState } from "react" +import { toast } from "sonner" import { CommandDialog, @@ -14,8 +15,6 @@ import { import { type UserDocument, useSearch } from "@/context/search" import { type FlattenedFileMapping } from "@/context/vault" -import { useToast } from "@/hooks/use-toast" - import type { FileSystemTreeNode, Vault } from "@/db/interfaces" type SearchCommandProps = { @@ -31,7 +30,6 @@ export function SearchCommand({ maps, vault, onFileSelect }: SearchCommandProps) const [results, setResults] = useState>([]) const [searchKey, setSearchKey] = useState(0) const { isIndexReady, searchDocuments } = useSearch() - const { toast } = useToast() // Debounce the query input to reduce the number of search operations useEffect(() => { @@ -111,7 +109,7 @@ export function SearchCommand({ maps, vault, onFileSelect }: SearchCommandProps) } catch (error) { if (isMounted) { console.error("Search error:", error) - toast({ title: "Cmd-K", description: "Failed to search query" }) + toast.error("Failed to search query") setResults([]) } } @@ -122,7 +120,7 @@ export function SearchCommand({ maps, vault, onFileSelect }: SearchCommandProps) return () => { isMounted = false } - }, [debouncedQuery, open, searchDocuments, maps, toast, isIndexReady]) + }, [debouncedQuery, open, searchDocuments, maps, isIndexReady]) // Update the search key when the dialog opens useEffect(() => { diff --git a/packages/morph/components/ui/sonner.tsx b/packages/morph/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/packages/morph/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/packages/morph/components/ui/toast.tsx b/packages/morph/components/ui/toast.tsx deleted file mode 100644 index a280462..0000000 --- a/packages/morph/components/ui/toast.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client" - -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { Cross1Icon } from "@radix-ui/react-icons" - -import { cn } from "@/lib/utils" - -const ToastProvider = ToastPrimitives.Provider - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName - -const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", - { - variants: { - variant: { - default: "border bg-background text-foreground", - destructive: - "destructive group border-destructive bg-destructive text-destructive-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Toast = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, ...props }, ref) => { - return ( - - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName - -const ToastAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastAction.displayName = ToastPrimitives.Action.displayName - -const ToastClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -ToastClose.displayName = ToastPrimitives.Close.displayName - -const ToastTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName - -const ToastDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName - -type ToastProps = React.ComponentPropsWithoutRef - -type ToastActionElement = React.ReactElement - -export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction, -} diff --git a/packages/morph/components/ui/toaster.tsx b/packages/morph/components/ui/toaster.tsx deleted file mode 100644 index 171beb4..0000000 --- a/packages/morph/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" - -import { useToast } from "@/hooks/use-toast" -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast" - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/packages/morph/context/providers.tsx b/packages/morph/context/providers.tsx index ca0404c..35f0665 100644 --- a/packages/morph/context/providers.tsx +++ b/packages/morph/context/providers.tsx @@ -121,12 +121,11 @@ export default memo(function ClientProvider({ children }: ClientProviderProps) { // Show some initial progress setLoadingProgress(0.3) - await initialize().then(async (db) => { - await applyMigrations(db, migrations) - // Set DB instance and finish loading - setDb(db) - setLoadingProgress(1) - }) + const dbInstance = await initialize() + await applyMigrations(dbInstance, migrations) + // Set DB instance and finish loading + setDb(dbInstance) + setLoadingProgress(1) } catch (err) { console.error("Error initializing database:", err) setLoadingProgress(1) diff --git a/packages/morph/db/index.ts b/packages/morph/db/index.ts index 74c5510..d70e920 100644 --- a/packages/morph/db/index.ts +++ b/packages/morph/db/index.ts @@ -120,4 +120,5 @@ export async function applyMigrations(db: MorphPgLite, migrations: MigrationMeta console.error("applyPgLiteMigrations: Migration process failed:", error) throw error } + return db } diff --git a/packages/morph/hooks/use-toast.ts b/packages/morph/hooks/use-toast.ts deleted file mode 100644 index 747fd4e..0000000 --- a/packages/morph/hooks/use-toast.ts +++ /dev/null @@ -1,189 +0,0 @@ -"use client" - -// Inspired by react-hot-toast library -import * as React from "react" - -import type { ToastActionElement, ToastProps } from "@/components/ui/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 100000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -export const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), - } - - case "DISMISS_TOAST": { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } -} - -export { useToast, toast } diff --git a/packages/morph/package.json b/packages/morph/package.json index 39049ea..389ede4 100644 --- a/packages/morph/package.json +++ b/packages/morph/package.json @@ -120,6 +120,7 @@ "remark-smartypants": "^3.0.2", "rfdc": "^1.4.1", "shiki": "^1.29.2", + "sonner": "^2.0.3", "tailwind-merge": "^3.1.0", "tailwindcss-animate": "^1.0.7", "three": "^0.174.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56c9631..636620a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -789,6 +789,9 @@ importers: shiki: specifier: ^1.29.2 version: 1.29.2 + sonner: + specifier: ^2.0.3 + version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.1.0 version: 3.1.0 @@ -6470,6 +6473,12 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} + sonner@2.0.3: + resolution: {integrity: sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -13138,6 +13147,11 @@ snapshots: slash@5.1.0: {} + sonner@2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} source-map-support@0.5.21: From 2ccae84c311ccc5789f28ca916ce6f4217fd0bff Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Mon, 7 Apr 2025 23:55:47 -0400 Subject: [PATCH 05/12] --wip-- Signed-off-by: Aaron Pham --- packages/morph/CLAUDE.md | 30 ++ packages/morph/app/layout.tsx | 2 +- packages/morph/components/editor.tsx | 264 +++++++++++------- packages/morph/components/markdown-inline.ts | 6 +- packages/morph/components/note-panel.tsx | 242 +--------------- packages/morph/components/reasoning-panel.tsx | 1 - packages/morph/components/ui/sonner.tsx | 11 + packages/morph/package.json | 4 +- packages/morph/services/notes.ts | 68 ++++- pnpm-lock.yaml | 104 +++---- 10 files changed, 333 insertions(+), 399 deletions(-) create mode 100644 packages/morph/CLAUDE.md diff --git a/packages/morph/CLAUDE.md b/packages/morph/CLAUDE.md new file mode 100644 index 0000000..2e684bb --- /dev/null +++ b/packages/morph/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +- Development: `pnpm dev` +- Build: `pnpm build` +- Lint: `pnpm lint` +- Format: `pnpm format` +- Format check: `pnpm format:check` +- Database migrations: `pnpm migrate` + +## Code Style Guidelines + +- TypeScript with strict typing (avoid `any` when possible) +- Use Next.js and React best practices with functional components +- Format: 100 char width, single quotes, no semicolons, trailing commas +- Naming: camelCase for variables/functions, PascalCase for components/classes, and snake-case for filenames. +- Imports: Follow order - React/Next imports first, then external libs, internal modules +- Error handling: Prefer early returns over deep nesting +- Component organization: Keep components focused on single responsibility +- Performance: Use memoization where appropriate, ensure proper cleanup in useEffect + +## Architecture + +- Next.js frontend with TypeScript +- DrizzleORM for database operations +- React Context for state management +- Follow existing patterns when adding new components or features diff --git a/packages/morph/app/layout.tsx b/packages/morph/app/layout.tsx index 66f60b1..8f0602e 100644 --- a/packages/morph/app/layout.tsx +++ b/packages/morph/app/layout.tsx @@ -49,7 +49,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo {children} - + {process.env.NODE_ENV === "production" && (