diff --git a/app/not-found.tsx b/app/not-found.tsx index 0b38538..c73534e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,6 +2,7 @@ import Link from "next/link" + export default function NotFound() { return (
diff --git a/app/page.tsx b/app/page.tsx index 948b1fe..de7084c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,6 +65,7 @@ export default function Page() { const [showHelpTooltip, setShowHelpTooltip] = useState(false) const helpTooltipTimer = useRef(null) const { settings, updateSettings, currentModel, isHydrated } = useAISettings() + const aiEnabled = settings.aiEnabled const debounceTimers = useRef>>({}) useEffect(() => { @@ -124,6 +125,12 @@ export default function Page() { if (helpTooltipTimer.current) clearTimeout(helpTooltipTimer.current) }, []) + useEffect(() => { + if (!aiEnabled) { + setIsGhostPanelOpen(false) + } + }, [aiEnabled]) + const undo = useCallback(() => { const stack = blockHistoryRef.current[activeProjectId] if (!stack || stack.length === 0) { @@ -155,7 +162,7 @@ export default function Page() { const activeProject = useMemo(() => projects.find(p => p.id === activeProjectId) || projects[0], - [projects, activeProjectId]) + [projects, activeProjectId]) const blocks = activeProject?.blocks || [] const ghostNotes = activeProject?.ghostNotes || [] @@ -445,6 +452,7 @@ export default function Page() { } const generateGhostNote = useCallback(async (projectId: string) => { + if (!aiEnabled) return const targetProject = projectsRef.current.find(p => p.id === projectId) if (!targetProject) return @@ -514,9 +522,10 @@ export default function Page() { } finally { generatingRef.current.delete(projectId) } - }, []) + }, [aiEnabled]) const enrichBlock = useCallback(async (projectId: string, id: string, text: string, category?: string, forcedType?: string) => { + if (!aiEnabled) return // Read context directly from the ref — avoids wrapping in setProjects() which // React StrictMode double-invokes in development, causing two concurrent // enrichment requests and a visible category flicker. @@ -545,8 +554,8 @@ export default function Page() { // the original block IDs so we get exact, rename-proof references. const influencedBy = data.influencedByIndices ? (data.influencedByIndices as number[]) - .map((idx) => context[idx]?.id) - .filter(Boolean) as string[] + .map((idx) => context[idx]?.id) + .filter(Boolean) as string[] : [] setProjects((current: Project[]) => { @@ -648,9 +657,10 @@ export default function Page() { blocks: proj.blocks.map(b => b.id === id ? { ...b, isEnriching: false, isError: true, statusText: errorStatus } : b) } : proj)) } - }, [generateGhostNote]) + }, [generateGhostNote, aiEnabled]) const claimGhostNote = useCallback((id: string) => { + if (!aiEnabled) return const note = (activeProject?.ghostNotes || []).find(n => n.id === id) if (!note || note.isGenerating) return const newId = generateId() @@ -672,7 +682,7 @@ export default function Page() { enrichBlock(p.id, newId, text, category, "thesis") return updatedProject }) - }, [activeProject, updateActiveProject, enrichBlock]) + }, [activeProject, updateActiveProject, enrichBlock, aiEnabled]) const dismissGhostNote = useCallback((id: string) => { updateActiveProject(p => ({ @@ -753,14 +763,16 @@ export default function Page() { text: resolvedText, timestamp: Date.now(), contentType: initialDisplayType, - isEnriching: true, + isEnriching: aiEnabled, }] })) setIsCommandKOpen(false) - enrichBlock(activeProjectId, newId, resolvedText, undefined, enrichForcedType).catch(console.error) + if (aiEnabled) { + enrichBlock(activeProjectId, newId, resolvedText, undefined, enrichForcedType).catch(console.error) + } }, - [activeProjectId, pushHistory, updateActiveProject, enrichBlock] + [activeProjectId, pushHistory, updateActiveProject, enrichBlock, aiEnabled] ) const deleteBlock = useCallback((id: string) => { @@ -795,29 +807,39 @@ export default function Page() { clearTimeout(debounceTimers.current[activeProjectId][id]) } - debounceTimers.current[activeProjectId][id] = setTimeout(() => { - enrichBlock(activeProjectId, id, newText, block.category).catch(console.error) - delete debounceTimers.current[activeProjectId][id] - }, 800) + if (aiEnabled) { + debounceTimers.current[activeProjectId][id] = setTimeout(() => { + enrichBlock(activeProjectId, id, newText, block.category).catch(console.error) + delete debounceTimers.current[activeProjectId][id] + }, 800) + } return prev.map(p => p.id === activeProjectId ? { ...p, - blocks: p.blocks.map(b => b.id === id ? { ...b, text: newText, isEnriching: true, isError: false } : b) + blocks: p.blocks.map(b => b.id === id ? { ...b, text: newText, isEnriching: aiEnabled, isError: false } : b) } : p) }) - }, [activeProjectId, enrichBlock, pushHistory]) + }, [activeProjectId, enrichBlock, pushHistory, aiEnabled]) const reEnrichBlock = useCallback((id: string, newCategory?: string) => { const block = blocksRef.current.find(b => b.id === id) if (!block) return + if (!aiEnabled) { + updateActiveProject(p => ({ + ...p, + blocks: p.blocks.map(b => b.id === id ? { ...b, category: newCategory } : b) + })) + return + } + updateActiveProject(p => ({ ...p, blocks: p.blocks.map(b => b.id === id ? { ...b, category: newCategory, isEnriching: true } : b) })) enrichBlock(activeProjectId, id, block.text, newCategory || block.category, block.contentType).catch(console.error) - }, [activeProjectId, updateActiveProject, enrichBlock]) + }, [activeProjectId, updateActiveProject, enrichBlock, aiEnabled]) const editAnnotation = useCallback((id: string, newAnnotation: string) => { updateActiveProject(p => ({ @@ -866,12 +888,19 @@ export default function Page() { const block = blocksRef.current.find(b => b.id === id) if (!block) return pushHistory(activeProjectId, blocksRef.current) + if (!aiEnabled) { + updateActiveProject(p => ({ + ...p, + blocks: p.blocks.map(b => b.id === id ? { ...b, contentType: newType } : b) + })) + return + } updateActiveProject(p => ({ ...p, blocks: p.blocks.map(b => b.id === id ? { ...b, contentType: newType, isEnriching: true } : b) })) enrichBlock(activeProjectId, id, block.text, block.category, newType).catch(console.error) - }, [activeProjectId, pushHistory, updateActiveProject, enrichBlock]) + }, [activeProjectId, pushHistory, updateActiveProject, enrichBlock, aiEnabled]) const clearBlocks = useCallback(() => { pushHistory(activeProjectId, blocksRef.current) @@ -967,7 +996,7 @@ export default function Page() { const handleCommand = useCallback((cmd: string, text?: string) => { setIsCommandKOpen(false) - + // Handle view switches if (cmd === "kanban") { setViewMode("kanban") @@ -989,12 +1018,13 @@ export default function Page() { setIsGhostPanelOpen(false) setIsIndexOpen(prev => !prev) } else if (cmd === "open-synthesis") { + if (!aiEnabled) return setIsSidebarOpen(false) setIsIndexOpen(false) setIsGhostPanelOpen(prev => !prev) } else if (cmd === "clear") clearBlocks() else if (cmd === "help") window.open("https://github.com/albingroen/react-cmdk", "_blank") - + // .nodepad export / import else if (cmd === "export-nodepad") { setProjects(prev => { @@ -1027,13 +1057,13 @@ export default function Page() { return prev }) } - + // Handle type overrides else if (cmd === "task" && text) addBlock(text, "task") else if (cmd === "thesis" && text) addBlock(text, "thesis") - + setIsCommandKOpen(false) - }, [clearBlocks, addBlock, activeProjectId]) + }, [clearBlocks, addBlock, activeProjectId, aiEnabled]) if (!isAuthReady) { return
@@ -1087,22 +1117,23 @@ export default function Page() { isSidebarOpen={isSidebarOpen} isIndexOpen={isIndexOpen} isGhostPanelOpen={isGhostPanelOpen} - ghostNoteCount={ghostNotes.filter(n => !n.isGenerating).length} + ghostNoteCount={aiEnabled ? ghostNotes.filter(n => !n.isGenerating).length : 0} activeProjectName={activeProject?.name || ""} onMenuClick={() => setIsSidebarOpen(!isSidebarOpen)} onIndexToggle={() => setIsIndexOpen(!isIndexOpen)} onGhostPanelToggle={() => setIsGhostPanelOpen(prev => !prev)} - modelLabel={isHydrated && settings.apiKey ? currentModel.shortLabel : undefined} + modelLabel={isHydrated && aiEnabled && settings.apiKey ? currentModel.shortLabel : undefined} showHelpTooltip={showHelpTooltip} sessionUsername={sessionUser.username} onLogout={handleLogout} + aiEnabled={aiEnabled} onHelpTooltipDismiss={() => { setShowHelpTooltip(false) if (helpTooltipTimer.current) clearTimeout(helpTooltipTimer.current) }} /> - {isHydrated && !settings.apiKey && ( + {isHydrated && aiEnabled && !settings.apiKey && (
⚡ AI enrichment requires an OpenRouter API key — use a free model (no credits needed) or add credits for GPT-4o, Claude, and more. Configure in the ☰ left panel.
@@ -1132,6 +1163,7 @@ export default function Page() { key={`tiling-${activeProjectId}`} blocks={activeProject.blocks} collapsedIds={new Set(activeProject.collapsedIds)} + aiEnabled={aiEnabled} onDelete={deleteBlock} onEdit={editBlock} onEditAnnotation={editAnnotation} @@ -1152,6 +1184,7 @@ export default function Page() {
- setIsIndexOpen(false)} isOpen={isIndexOpen} diff --git a/components/kanban-area.tsx b/components/kanban-area.tsx index 166b109..fe5f526 100644 --- a/components/kanban-area.tsx +++ b/components/kanban-area.tsx @@ -10,6 +10,7 @@ import { KanbanMinimap } from "./kanban-minimap" interface KanbanAreaProps { blocks: TextBlock[] + aiEnabled: boolean onDelete: (id: string) => void onEdit: (id: string, newText: string) => void onEditAnnotation: (id: string, newAnnotation: string) => void @@ -28,6 +29,7 @@ interface KanbanAreaProps { export function KanbanArea({ blocks, + aiEnabled, onDelete, onEdit, onEditAnnotation, @@ -170,6 +172,7 @@ export function KanbanArea({ onConnectionLock={handleConnectionLock} isConnectionLocked={lockedConnectionId === block.id} allBlocks={blocks} + aiEnabled={aiEnabled} workspaces={workspaces} activeWorkspaceId={activeWorkspaceId} onMoveToWorkspace={onMoveToWorkspace} diff --git a/components/project-sidebar.tsx b/components/project-sidebar.tsx index 6084036..db02430 100644 --- a/components/project-sidebar.tsx +++ b/components/project-sidebar.tsx @@ -17,6 +17,7 @@ import { Eye, EyeOff, Save, + Sparkles, FolderInput, } from "lucide-react" import { @@ -355,6 +356,29 @@ export function ProjectSidebar({
+ {/* AI Enabled */} +
+
+ +
+
AI Features
+
+ Toggle all AI enrichment and synthesis features. +
+
+
+ +
+ {/* API Key */}
)} - {/* API Status */} -
- - {draft.apiKey ? `${currentPreset.label} — API key configured` : "No API key — AI disabled"} -
- - )} - -
+{/* API Status */} +
+ + {draft.aiEnabled + ? ( + draft.apiKey + ? `${currentPreset.label} — API key configured` + : "No API key — AI disabled" + ) + : "AI disabled"} +
{/* Footer */}
diff --git a/components/status-bar.tsx b/components/status-bar.tsx index f70c6df..7262bae 100644 --- a/components/status-bar.tsx +++ b/components/status-bar.tsx @@ -24,6 +24,7 @@ interface StatusBarProps { onHelpTooltipDismiss?: () => void sessionUsername?: string onLogout?: () => void + aiEnabled: boolean } export function StatusBar({ @@ -42,16 +43,19 @@ export function StatusBar({ onHelpTooltipDismiss, sessionUsername, onLogout, + aiEnabled, }: StatusBarProps) { const [time, setTime] = useState("") const [isAboutOpen, setIsAboutOpen] = useState(false) + const [showSynthesisTooltip, setShowSynthesisTooltip] = useState(false) + const synthesisTooltipTimer = useRef(null) const activity = useMemo(() => { return { - enriching: blocks.filter(b => b.isEnriching).length, - errors: blocks.filter(b => b.isError).length + enriching: aiEnabled ? blocks.filter(b => b.isEnriching).length : 0, + errors: aiEnabled ? blocks.filter(b => b.isError).length : 0 } - }, [blocks]) + }, [blocks, aiEnabled]) useEffect(() => { const update = () => @@ -66,6 +70,10 @@ export function StatusBar({ return () => clearInterval(interval) }, []) + useEffect(() => () => { + if (synthesisTooltipTimer.current) clearTimeout(synthesisTooltipTimer.current) + }, []) + const typeCounts = useMemo(() => { const counts: Record = {} blocks.forEach((b) => { @@ -186,13 +194,24 @@ export function StatusBar({ {/* Ghost panel toggle with badge */}
) : (
- {block.isError && ( + {block.isError && aiEnabled && (
{block.statusText === "no-api-key" diff --git a/components/tiling-area.tsx b/components/tiling-area.tsx index fb352e5..914940a 100644 --- a/components/tiling-area.tsx +++ b/components/tiling-area.tsx @@ -41,6 +41,7 @@ interface BSPNode { interface TilingAreaProps { blocks: TextBlock[] collapsedIds: Set + aiEnabled: boolean onDelete: (id: string) => void onEdit: (id: string, newText: string) => void onEditAnnotation: (id: string, newAnnotation: string) => void @@ -61,6 +62,7 @@ interface TilingAreaProps { export function TilingArea({ blocks, collapsedIds, + aiEnabled, onDelete, onEdit, onEditAnnotation, @@ -194,30 +196,35 @@ export function TilingArea({ className="flex flex-1 p-0.5 overflow-hidden" >
- +
) @@ -246,29 +253,35 @@ export function TilingArea({ {/* Task Header stays sticky at top */} {taskBlock && (
- +
)} diff --git a/lib/ai-settings.ts b/lib/ai-settings.ts index 02dc657..ce32c7f 100644 --- a/lib/ai-settings.ts +++ b/lib/ai-settings.ts @@ -186,6 +186,7 @@ export const DEFAULT_MODEL_ID = "openai/gpt-4o" export const DEFAULT_PROVIDER: AIProvider = "openrouter" export interface AISettings { + aiEnabled: boolean apiKey: string modelId: string webGrounding: boolean @@ -201,6 +202,7 @@ const STORAGE_KEY = "nodepad-ai-settings" function loadSettings(): AISettings { if (typeof window === "undefined") { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -213,6 +215,7 @@ function loadSettings(): AISettings { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -222,6 +225,7 @@ function loadSettings(): AISettings { } } return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -232,6 +236,7 @@ function loadSettings(): AISettings { } } catch { return { + aiEnabled: true, apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, @@ -252,7 +257,7 @@ export interface AIConfig { export function loadAIConfig(): AIConfig | null { const s = loadSettings() - if (!s.apiKey) return null + if (!s.aiEnabled || !s.apiKey) return null const models = getModelsForProvider(s.provider) const model = models.find(m => m.id === s.modelId) const isCustomOpenRouter = s.provider === "openrouter" && s.modelId === CUSTOM_OPENROUTER_MODEL_ID @@ -311,8 +316,13 @@ export function useAISettings() { // caused by settings.apiKey toggling conditional DOM blocks (API key banner, // modelLabel prop, etc.) between the server render and client hydration. const [settings, setSettings] = useState({ - apiKey: "", modelId: DEFAULT_MODEL_ID, webGrounding: false, - provider: DEFAULT_PROVIDER, customBaseUrl: "", openrouterCustomModelId: "", + aiEnabled: true, + apiKey: "", + modelId: DEFAULT_MODEL_ID, + webGrounding: false, + provider: DEFAULT_PROVIDER, + customBaseUrl: "", + openrouterCustomModelId: "", }) const [isHydrated, setIsHydrated] = useState(false)