diff --git a/changelogs/v2.5.5.md b/changelogs/v2.5.5.md new file mode 100644 index 00000000..98b74b84 --- /dev/null +++ b/changelogs/v2.5.5.md @@ -0,0 +1,17 @@ +# v2.5.5 + +## Added + +- **Chat settings, without leaving the chat.** A new gear icon in the chat toolbar opens a compact popover to change the model, max tool-call steps, temperature, and subagents on the spot — no round-trip to Settings just to bump the step limit. The model picker uses the app's dropdown with a **Refresh models** action (re-queries your endpoint) and a **Custom model…** option for typing any model id the endpoint doesn't list. A "More settings…" link jumps to the full AI & Chat settings when you need endpoints, API keys, or MCP. +- **The notes folder tree now remembers which folders are expanded.** Collapse/expand state is saved per project and restored when you switch views, change projects, or reload — no more re-opening the same folders every time you return to Notes. + +## Changed + +- **Subagents is now a single global preference** (in Settings → AI & Chat, and in the new in-chat gear popover) instead of a per-conversation toggle. Turn it on once and every chat uses the dispatch → research/write architecture. The old per-thread "Subagents" pill in the chat toolbar has been replaced by the gear popover. +- **The "Move to root" drop zone is now pinned to the bottom of the notes panel** while dragging, instead of appearing inline between folders — so it's always in the same, easy-to-reach spot no matter how far you've scrolled. + +## Fixed + +- **Deleting a note no longer briefly "comes back" during sync.** When you deleted a note on desktop and then synced again before the other device (e.g. your phone) had picked up the deletion, the peer's still-stale copy of the note could be re-applied and resurrect the note — until the other device finally synced the deletion and it disappeared again. The desktop deletes rows physically (leaving no local tombstone), so the sync engine had nothing to compare an incoming older edit against and let it win. The engine now leaves a tombstone marker whenever a hard-deleted row is drained, so a stale edit from a peer can no longer bring a deleted note back. Deletions now stick immediately and both devices converge on the delete. +- **Changing the chat tool-call limit ("Max steps") now sticks.** Setting a new limit (including "∞") in Settings would appear to work, then quietly revert to 30 partway through a conversation. The setting was being dropped from the backend settings cache, so the next time the app refreshed its config — which happens automatically after any turn that writes with a tool — the limit reset to the default. The full AI settings (max steps, temperature, context limit, AI-enabled) are now preserved end to end, and the config refresh no longer overwrites richer locally-saved settings with a partial cache. +- **The "Move to root" notes drop zone no longer lingers after dragging a note or folder onto another project.** Because the dragged item moves out of the current project (and its row unmounts) the moment you drop it on another project, the drag-end that clears the drop zone never fired — so the zone stayed on screen until the next click. Drag state is now cleared reliably via a document-level listener, so the zone disappears as soon as you let go. diff --git a/electron/lib/config-cache.test.ts b/electron/lib/config-cache.test.ts index 9567d724..1cbb6470 100644 --- a/electron/lib/config-cache.test.ts +++ b/electron/lib/config-cache.test.ts @@ -14,7 +14,70 @@ * 2. getEmbeddingsSettingsCached() must run without throwing when electron * is unavailable — returning a plain object (possibly empty). */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +// Point config-cache's userData resolver at a temp dir so the round-trip tests +// below never touch the developer's real ai-settings-cache.json. +let tmpDir: string; +vi.mock("../runtime/port-discovery", () => ({ + findUserDataDir: () => tmpDir, +})); + +describe("config-cache AI settings round-trip", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-cfg-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("persists maxSteps (and other behavioural fields) for the 'ai' config", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + saveCachedConfig("ai", { + provider: "openai", + baseUrl: "https://api.openai.com", + model: "gpt-4o", + apiKey: "sk-x", + maxSteps: 1000, + temperature: 0.5, + contextLimit: 200000, + aiEnabled: true, + subagentsEnabled: true, + }); + const ai = getCachedConfig().aiConfig; + // Regression: maxSteps used to be silently dropped here, which reset the + // chat tool-call limit to the default (30) on the next hydrate. + expect(ai?.maxSteps).toBe(1000); + expect(ai?.temperature).toBe(0.5); + expect(ai?.contextLimit).toBe(200000); + expect(ai?.aiEnabled).toBe(true); + expect(ai?.subagentsEnabled).toBe(true); + expect(ai?.model).toBe("gpt-4o"); + }); + + it("persists subagentsEnabled (global subagents preference)", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + saveCachedConfig("ai", { subagentsEnabled: true }); + expect(getCachedConfig().aiConfig?.subagentsEnabled).toBe(true); + // A later connection-only save must not clobber it. + saveCachedConfig("ai", { model: "gpt-4o" }); + expect(getCachedConfig().aiConfig?.subagentsEnabled).toBe(true); + }); + + it("a later connection-only save preserves an existing maxSteps", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + saveCachedConfig("ai", { maxSteps: 500 }); + // A subsequent save that only carries connection fields (e.g. from the + // provider picker) must not wipe the previously-stored maxSteps. + saveCachedConfig("ai", { model: "gpt-4o-mini" }); + const ai = getCachedConfig().aiConfig; + expect(ai?.maxSteps).toBe(500); + expect(ai?.model).toBe("gpt-4o-mini"); + }); +}); describe("config-cache without Electron", () => { it("imports without throwing the electron install error", async () => { diff --git a/electron/lib/config-cache.ts b/electron/lib/config-cache.ts index f80e0bc8..11c717c7 100644 --- a/electron/lib/config-cache.ts +++ b/electron/lib/config-cache.ts @@ -15,6 +15,15 @@ export interface CachedConfig { baseUrl?: string; model?: string; apiKey?: string; + // Persist the FULL renderer AIConfig, not just connection fields. Dropping + // these here caused hydrateFromElectron() to reset them to DEFAULT_AI_CONFIG + // after every write-tool turn — e.g. the chat tool-call limit (maxSteps) + // silently "reverted to 30" mid-conversation. + maxSteps?: number; + temperature?: number; + contextLimit?: number; + aiEnabled?: boolean; + subagentsEnabled?: boolean; }; agentConfig?: { baseUrl?: string; @@ -80,6 +89,13 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | baseUrl: typeof configRecord.baseUrl === "string" ? configRecord.baseUrl : current.aiConfig?.baseUrl, model: typeof configRecord.model === "string" ? configRecord.model : current.aiConfig?.model, apiKey: typeof configRecord.apiKey === "string" ? configRecord.apiKey : current.aiConfig?.apiKey, + // Preserve behavioural fields too — omitting maxSteps here made the chat + // tool-call limit reset to the default on the next hydrate. + maxSteps: typeof configRecord.maxSteps === "number" ? configRecord.maxSteps : current.aiConfig?.maxSteps, + temperature: typeof configRecord.temperature === "number" ? configRecord.temperature : current.aiConfig?.temperature, + contextLimit: typeof configRecord.contextLimit === "number" ? configRecord.contextLimit : current.aiConfig?.contextLimit, + aiEnabled: typeof configRecord.aiEnabled === "boolean" ? configRecord.aiEnabled : current.aiConfig?.aiEnabled, + subagentsEnabled: typeof configRecord.subagentsEnabled === "boolean" ? configRecord.subagentsEnabled : current.aiConfig?.subagentsEnabled, }; } else if (type === "agent" && configRecord) { current.agentConfig = { diff --git a/shared/sync/engine.test.ts b/shared/sync/engine.test.ts index 90e025f7..381b874b 100644 --- a/shared/sync/engine.test.ts +++ b/shared/sync/engine.test.ts @@ -749,6 +749,35 @@ describe("sync engine — trigger→drain capture of real queries.ts writes", () expect((B.db.prepare("SELECT COUNT(*) c FROM notes WHERE id='n1' AND deleted_at IS NULL").get() as { c: number }).c).toBe(0); }); + it("a stale peer put drained AFTER a local hard-delete does not resurrect the row", () => { + // Real-world timing bug: desktop deletes a note (physical DELETE, no local + // tombstone), publishes the delete, then syncs AGAIN and reads the phone's + // still-stale oplog (a put@lowerHLC published before the phone saw the + // delete). With no local tombstone the staleness guard has nothing to + // compare against, so the older put would resurrect the note. The + // drainPending tombstone-shell fix keeps the delete sticky. + const clkA = clockFrom(12_150_000); + const clkB = clockFrom(12_150_000); + const A = makeDevice("A", clkA.now); + const B = makeDevice("B", clkB.now); + seedNoteOnBoth(A, B); + // Phone publishes its current (live) state — a put with the seed's HLC. + writeOplogFile(dir, "B", B.engine.exportOplog()); + expect((B.db.prepare("SELECT COUNT(*) c FROM notes WHERE id='n1' AND deleted_at IS NULL").get() as { c: number }).c).toBe(1); + + // Desktop deletes and republishes (higher HLC). + clkA.advance(100); + q.deleteNote(A.db, "n1"); + A.engine.drainPending(); + writeOplogFile(dir, "A", A.engine.exportOplog()); + expect((A.db.prepare("SELECT COUNT(*) c FROM notes WHERE id='n1' AND deleted_at IS NULL").get() as { c: number }).c).toBe(0); + + // Desktop syncs again while the phone is still offline: it reconciles the + // phone's stale put. The note must STAY deleted on desktop. + A.engine.applyRemote(readPeerOplogs(dir, "A")); + expect((A.db.prepare("SELECT COUNT(*) c FROM notes WHERE id='n1' AND deleted_at IS NULL").get() as { c: number }).c).toBe(0); + }); + it("a concurrent higher-HLC peer edit wins over an earlier hard-delete (row comes back on both)", () => { // Desktop hard-deletes n1, but the phone had a LATER edit it hadn't synced // yet. LWW: the higher-HLC edit wins, so the note is restored — and both diff --git a/shared/sync/engine.ts b/shared/sync/engine.ts index e99b0de3..86a34656 100644 --- a/shared/sync/engine.ts +++ b/shared/sync/engine.ts @@ -188,6 +188,15 @@ export class SyncEngine { if (p.seq > maxSeq) maxSeq = p.seq; } + // A hard-deleted row leaves no tombstone, so we may need to insert a + // tombstone SHELL (placeholder FK columns) to keep the staleness guard armed + // against a later stale peer 'put'. Shells legitimately violate FKs, so + // disable enforcement around the transaction (the pragma is a no-op inside + // one) exactly as applyRemote does. Restored in `finally`. + const fkRow = this.db.prepare("PRAGMA foreign_keys").get() as { foreign_keys?: number } | undefined; + const fkWasOn = fkRow?.foreign_keys === 1; + if (fkWasOn) this.db.prepare("PRAGMA foreign_keys = OFF").run(); + const run = this.db.transaction(() => { this.setSuppress(true); // our hlc/deleted_at writes below must not re-stage for (const { entity, entity_id, op } of latest.values()) { @@ -195,7 +204,19 @@ export class SyncEngine { const row = readRow(this.db, entity, entity_id); if (op === "delete" || !row) { // Row gone (hard delete) or explicitly tombstoned. - this.db.prepare(`UPDATE ${entity} SET deleted_at = COALESCE(deleted_at, ?), hlc = ? WHERE id = ?`).run(nowIso(), stamp, entity_id); + const upd = this.db + .prepare(`UPDATE ${entity} SET deleted_at = COALESCE(deleted_at, ?), hlc = ? WHERE id = ?`) + .run(nowIso(), stamp, entity_id) as { changes?: number }; + // Desktop's q.deleteNote does a physical `DELETE FROM notes`, so by the + // time we drain the staged 'delete' the row is already gone and the + // UPDATE above matches zero rows — leaving NO local tombstone. Without a + // tombstone, a later peer 'put' with an OLDER HLC hits reconcileOne with + // local=undefined (localHlc=null), bypasses the staleness guard, and + // RESURRECTS the deleted row. Insert a tombstone shell (same as the + // delete-arrives-before-insert case) so the staleness guard trips and + // the stale put stays dead. FK columns on the shell are placeholders; + // the row is a tombstone and never read as live data. + if ((upd.changes ?? 0) === 0) this.insertTombstoneShell(entity, entity_id, stamp); this.appendOplog({ hlc: stamp, origin: this.deviceId, entity, entity_id, op: "delete", payload: null }); } else { this.db.prepare(`UPDATE ${entity} SET hlc = ? WHERE id = ?`).run(stamp, entity_id); @@ -218,7 +239,11 @@ export class SyncEngine { this.setSuppress(false); this.persistHlc(); }); - run(); + try { + run(); + } finally { + if (fkWasOn) this.db.prepare("PRAGMA foreign_keys = ON").run(); + } return latest.size; } diff --git a/src/components/chat/chat-panel/ChatQuickSettings.tsx b/src/components/chat/chat-panel/ChatQuickSettings.tsx new file mode 100644 index 00000000..c24fabb7 --- /dev/null +++ b/src/components/chat/chat-panel/ChatQuickSettings.tsx @@ -0,0 +1,345 @@ +"use client"; + +import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; +import { Settings2, GitBranch, ExternalLink, ChevronDown, RefreshCw, Check, Pencil } from "lucide-react"; +import { useCairnStore } from "@/store"; +import { useShallow } from "zustand/react/shallow"; +import { cn } from "@/lib/utils"; +import { Tooltip } from "@/components/ui/tooltip"; +import { Toggle } from "@/components/ui/toggle"; +import { + DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, +} from "@/components/ui/dropdown"; +import { useEndpointConfig, isLocalBaseUrl, LOCAL_FALLBACK_MODELS } from "@/components/settings/endpoint-components"; + +const MAX_STEPS_PRESETS = [10, 20, 30, 50, 1000] as const; +const CLOUD_FALLBACK_MODELS = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo", "gpt-4", "o1-mini", "o3-mini"]; + +/** + * In-chat quick-settings popover. Surfaces the handful of AI settings people + * change *while chatting* — model, max steps, temperature, subagents — editing + * the SAME global aiConfig as Settings → AI & Chat (no per-thread overrides). + * The full endpoint / API-key / MCP surface stays in Settings behind the + * "More settings…" link. Replaces the old inline Subagents pill. + * + * `disabled` mirrors the toolbar's isLoading guard so settings can't be changed + * mid-stream. + */ +export function ChatQuickSettings({ disabled }: { disabled?: boolean }) { + const { aiConfig, setAIConfig, setSettingsSection, setView } = useCairnStore( + useShallow((s) => ({ + aiConfig: s.aiConfig, + setAIConfig: s.setAIConfig, + setSettingsSection: s.setSettingsSection, + setView: s.setView, + })), + ); + + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const { availableModels, fetchModels, modelsLoading, testState } = useEndpointConfig(); + + const provider = aiConfig.provider ?? "openai"; + const isLocal = isLocalBaseUrl(aiConfig.baseUrl); + const subagentsSupported = provider !== "localllm"; + + // Fetch the endpoint's model list once when the popover opens (cloud only). + // Users can re-fetch anytime via the Refresh action in the model picker. + useEffect(() => { + if (open && provider !== "localllm") { + fetchModels(aiConfig.baseUrl, aiConfig.apiKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Close on outside click / Escape. The model picker uses a Radix dropdown + // whose content is PORTALED outside rootRef — clicking a model item must not + // close the whole popover, so ignore clicks landing inside any Radix popper. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Element | null; + if (target?.closest("[data-radix-popper-content-wrapper]")) return; + if (rootRef.current && !rootRef.current.contains(target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const openFullSettings = useCallback(() => { + setSettingsSection("ai"); + setView("settings"); + setOpen(false); + }, [setSettingsSection, setView]); + + const modelOptions = availableModels.length > 0 + ? availableModels + : (isLocal ? LOCAL_FALLBACK_MODELS : CLOUD_FALLBACK_MODELS); + + const maxSteps = aiConfig.maxSteps ?? 30; + const temperature = aiConfig.temperature ?? 0.3; + + return ( +
+ + + + + {open && ( +
+
+ Chat settings +
+ + {/* Model */} +
+ Model + setAIConfig({ model: m })} + onRefresh={() => fetchModels(aiConfig.baseUrl, aiConfig.apiKey)} + /> +
+ + {/* Max steps */} +
+
+ Max steps + + {maxSteps === 1000 ? "∞" : maxSteps} tool rounds + +
+
+ {MAX_STEPS_PRESETS.map((n) => ( + + ))} +
+
+ + {/* Temperature */} + + + {/* Subagents (cloud only) */} + {subagentsSupported && ( +
+ + + Subagents + + setAIConfig({ subagentsEnabled: v })} + disabled={disabled} + label="Enable subagents" + /> +
+ )} + + +
+ )} +
+ ); +} + +/** + * A single-line, truncated label that shows the app Tooltip with the full text + * ONLY when the text is actually cut off (scrollWidth > clientWidth). Avoids a + * pointless tooltip on names that already fit. + */ +function TruncatedModel({ text, className }: { text: string; className?: string }) { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [text]); + + const span = ( + + {text} + + ); + + if (!overflowing) return span; + return ( + + {span} + + ); +} + +/** + * Compact model selector for the quick-settings popover. Uses the shared Radix + * dropdown (consistent floating panel + click-away) to pick from the endpoint's + * fetched models, with a Refresh action to re-query the endpoint and a + * "Custom model…" affordance for typing any model id the endpoint didn't list. + */ +function ModelPicker({ + value, + options, + loading, + errored, + disabled, + placeholder, + onChange, + onRefresh, +}: { + value: string; + options: string[]; + loading: boolean; + errored: boolean; + disabled?: boolean; + placeholder?: string; + onChange: (model: string) => void; + onRefresh: () => void; +}) { + // Custom-entry mode: a free-text input for a model id not in the list. + const [custom, setCustom] = useState(false); + const customRef = useRef(null); + useEffect(() => { if (custom) customRef.current?.focus(); }, [custom]); + + if (custom) { + return ( +
+ onChange(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setCustom(false); }} + disabled={disabled} + placeholder={placeholder} + className="flex-1 min-w-0 px-2 py-1 text-[0.714rem] font-mono rounded-md border border-[var(--border)] bg-[var(--surface-2)] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" + /> + + + +
+ ); + } + + return ( +
+ + + + + + { e.preventDefault(); onRefresh(); }} + className="text-[var(--text-secondary)]" + > + + {loading ? "Refreshing…" : "Refresh models"} + + setCustom(true)}> + + Custom model… + + + {errored && ( +
+ Couldn't load models — check the endpoint in settings. +
+ )} + {options.length === 0 && !errored && ( +
+ No models — Refresh or add a custom one. +
+ )} + {options.map((m) => ( + onChange(m)} + className={cn("font-mono text-xs", m === value && "text-[var(--accent)]")} + > + + {m === value && } + + + + ))} +
+
+
+ ); +} diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index 7fb1b7a4..4b9a3b41 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; -import { Trash2, ChevronDown, ArrowLeftFromLine, GitBranch } from "lucide-react"; +import { Trash2, ChevronDown, ArrowLeftFromLine } from "lucide-react"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { useChatStream } from "@/hooks/useChatStream"; @@ -14,6 +14,7 @@ import type { ChatHistoryEntry } from "@/types"; import { Tooltip } from "@/components/ui/tooltip"; import { ChatMessageBubble } from "./ChatMessageBubble"; import { ChatSubagentBlock } from "./ChatSubagentBlock"; +import { ChatQuickSettings } from "./ChatQuickSettings"; import { SuggestedPrompts } from "./SuggestedPrompts"; import { ToolCallIndicator } from "./ToolCallIndicator"; import { QuestionForm } from "./QuestionForm"; @@ -129,7 +130,6 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP const [projectOpen, setProjectOpen] = useState(false); const { isLoading, toolCalls, streamingContent, streamingThought, subagents, pendingQuestions, sendStream, stopStream } = useChatStream(threadId); - const setThreadUseSubagents = useCairnStore((s) => s.setThreadUseSubagents); const project = useMemo(() => projects.find((p) => p.id === activeProjectId), [projects, activeProjectId]); const workspace = useMemo(() => workspaces.find((w) => w.id === activeWorkspaceId), [workspaces, activeWorkspaceId]); @@ -470,9 +470,11 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP }, systemPrompt, images: imagesToSend?.map((img) => ({ name: img.name, dataUrl: img.dataUrl })), - useSubagents: activeThread?.useSubagents ?? false, + // Subagents is now a GLOBAL AI setting (aiConfig.subagentsEnabled), not a + // per-thread flag. Ignored server-side / here for the localllm provider. + useSubagents: aiConfig.provider !== "localllm" && (aiConfig.subagentsEnabled ?? false), }); - }, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project, pendingImages, activeThread]); + }, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project, pendingImages]); const handleRetry = useCallback((content: string) => { handleSend(content); @@ -573,28 +575,8 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP /> )} - {threadId && aiConfig.provider !== "localllm" && ( - - - + {threadId && ( + )} diff --git a/src/components/notes/notes-view.tsx b/src/components/notes/notes-view.tsx index a5398fc7..b8fc6033 100644 --- a/src/components/notes/notes-view.tsx +++ b/src/components/notes/notes-view.tsx @@ -46,6 +46,9 @@ export function NotesView() { aiConfig, notesSidebarWidth, setNotesSidebarWidth, + notesCollapsedFolders, + toggleNotesFolder, + setNotesFolderCollapsed, notesFullscreen, toggleNotesFullscreen, } = useCairnStore(useShallow((s) => ({ @@ -69,6 +72,9 @@ export function NotesView() { aiConfig: s.aiConfig, notesSidebarWidth: s.notesSidebarWidth, setNotesSidebarWidth: s.setNotesSidebarWidth, + notesCollapsedFolders: s.notesCollapsedFolders, + toggleNotesFolder: s.toggleNotesFolder, + setNotesFolderCollapsed: s.setNotesFolderCollapsed, notesFullscreen: s.notesFullscreen, toggleNotesFullscreen: s.toggleNotesFullscreen, }))); @@ -134,8 +140,19 @@ export function NotesView() { }; }, [setNotesSidebarWidth]); - // folder → collapsed state; true = collapsed, undefined/false = open - const [collapsedFolders, setCollapsedFolders] = useState>({}); + // Folder collapse state is persisted per-project in the store (keyed by + // `${projectId}:${lowercasedPath}`). Derive a plain-path map for the ACTIVE + // project so FolderTreeNode can keep looking up by node.path unchanged. + // Presence = collapsed; absence = expanded (folders default open). + const collapsedFolders = useMemo(() => { + if (!activeProjectId) return {} as Record; + const prefix = `${activeProjectId}:`; + const out: Record = {}; + for (const [key, val] of Object.entries(notesCollapsedFolders)) { + if (val && key.startsWith(prefix)) out[key.slice(prefix.length)] = true; + } + return out; + }, [notesCollapsedFolders, activeProjectId]); // ⌘F / Ctrl+F — focus the filter input, but only when the CM6 editor // is NOT focused (CM6's own searchKeymap handles it when the editor is active). @@ -246,6 +263,28 @@ export function NotesView() { setIsDragging(false); }, [moveNoteToFolder, moveFolder, activeProjectId]); + // Belt-and-suspenders drag-state reset. The source element's onDragEnd is the + // primary reset, but when a note/folder is dropped onto ANOTHER project it is + // moved out of this project's list and its DOM node unmounts mid-drag — so its + // onDragEnd never fires and the pinned "Move to root" zone would linger. A + // document-level dragend/drop listener (active only while dragging) clears the + // local drag UI regardless of which element ended the drag. + useEffect(() => { + if (!isDragging) return; + const reset = () => { + dragNoteIdRef.current = null; + dragFolderPathRef.current = null; + setDropTarget(null); + setIsDragging(false); + }; + document.addEventListener("dragend", reset); + document.addEventListener("drop", reset); + return () => { + document.removeEventListener("dragend", reset); + document.removeEventListener("drop", reset); + }; + }, [isDragging]); + // Subscribe to allNotes directly so this component re-renders when note // content changes (e.g. while typing). The selector functions are stable @@ -329,8 +368,8 @@ export function NotesView() { } const toggleFolder = useCallback((folderPath: string) => { - setCollapsedFolders((prev) => ({ ...prev, [folderPath]: !prev[folderPath] })); - }, []); + if (activeProjectId) toggleNotesFolder(activeProjectId, folderPath); + }, [activeProjectId, toggleNotesFolder]); function handleCreateDashboard() { if (!activeProjectId) return; @@ -609,22 +648,6 @@ export function NotesView() { onFolderDrop={handleFolderDrop} /> ))} - {/* Root drop zone — visible only while dragging, when folders exist */} - {folderTree.length > 0 && (dropTarget !== null || isDragging) && ( -
{ e.preventDefault(); setDropTarget("__root__"); }} - onDragLeave={() => setDropTarget(null)} - onDrop={(e) => { e.preventDefault(); handleFolderDrop(""); }} - > - Move to root -
- )} {/* Root notes (no folder) */} {rootNotes.map((note) => ( + {/* Move-to-root drop zone — pinned to the bottom of the panel, shown only + while dragging a note/folder within THIS project (not a cross-project + drag). Sits above the New-note footer as a flex-shrink-0 sibling so it + never scrolls with the list. */} + {!isFiltering && folderTree.length > 0 && isDragging && ( +
{ e.preventDefault(); setDropTarget("__root__"); }} + onDragLeave={() => setDropTarget(null)} + onDrop={(e) => { e.preventDefault(); handleFolderDrop(""); }} + > + Move to root +
+ )} + {/* Footer */}