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.
+
+
+
+
setDraft(d => ({ ...d, aiEnabled: !d.aiEnabled }))}
+ className={`relative shrink-0 h-5 w-9 rounded-full transition-all duration-200 ${
+ draft.aiEnabled ? "bg-primary" : "bg-white/10"
+ }`}
+ >
+
+
+
+
{/* API Key */}
@@ -553,18 +577,29 @@ export function ProjectSidebar({
)}
- {/* 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 */}
{
+ if (!aiEnabled) {
+ setShowSynthesisTooltip(true)
+ if (synthesisTooltipTimer.current) clearTimeout(synthesisTooltipTimer.current)
+ synthesisTooltipTimer.current = setTimeout(() => setShowSynthesisTooltip(false), 2400)
+ return
+ }
+ onGhostPanelToggle()
+ }}
className={`relative p-1.5 rounded-sm transition-all duration-200 ${
- isGhostPanelOpen
- ? "bg-primary/20 text-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]"
- : "hover:bg-secondary text-muted-foreground/50 hover:text-foreground"
+ !aiEnabled
+ ? "text-muted-foreground/40"
+ : isGhostPanelOpen
+ ? "bg-primary/20 text-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]"
+ : "hover:bg-secondary text-muted-foreground/50 hover:text-foreground"
}`}
title="Synthesis Panel"
+ aria-disabled={!aiEnabled}
>
{ghostNoteCount > 0 && !isGhostPanelOpen && (
@@ -200,6 +219,24 @@ export function StatusBar({
{ghostNoteCount}
)}
+
+ {showSynthesisTooltip && !aiEnabled && (
+
+
+
+
+ Synthesis requires an AI provider. Enable it in the settings.
+
+
+
+ )}
+
void
onEdit: (id: string, newText: string) => void
onEditAnnotation: (id: string, newAnnotation: string) => void
@@ -45,6 +45,11 @@ interface TileCardProps {
isConnectionLocked?: boolean
allBlocks?: TextBlock[]
onChangeType?: (id: string, newType: ContentType) => void
+
+ // feat/llm_toggle
+ aiEnabled?: boolean
+
+ // main
workspaces?: { id: string; name: string }[]
activeWorkspaceId?: string
onMoveToWorkspace?: (blockId: string, targetWorkspaceId: string) => void
@@ -86,13 +91,13 @@ function isRTL(text: string): boolean {
return rtlChars.test(text)
}
-export const TileCard = memo(function TileCard({
- block,
- isCollapsed,
- onDelete,
- onEdit,
- onEditAnnotation,
- onReEnrich,
+export const TileCard = memo(function TileCard({
+ block,
+ isCollapsed,
+ onDelete,
+ onEdit,
+ onEditAnnotation,
+ onReEnrich,
onToggleCollapse,
onTogglePin,
onToggleSubTask,
@@ -105,6 +110,11 @@ export const TileCard = memo(function TileCard({
allBlocks,
hideCollapse = false,
onChangeType,
+
+ // feat/llm_toggle
+ aiEnabled = true,
+
+ // main
workspaces,
activeWorkspaceId,
onMoveToWorkspace,
@@ -637,7 +647,7 @@ export const TileCard = memo(function TileCard({
) : (
- {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)