Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Link from "next/link"


export default function NotFound() {
return (
<div className="relative flex h-screen w-screen flex-col items-center justify-center overflow-hidden bg-background">
Expand Down
91 changes: 62 additions & 29 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export default function Page() {
const [showHelpTooltip, setShowHelpTooltip] = useState(false)
const helpTooltipTimer = useRef<NodeJS.Timeout | null>(null)
const { settings, updateSettings, currentModel, isHydrated } = useAISettings()
const aiEnabled = settings.aiEnabled
const debounceTimers = useRef<Record<string, Record<string, NodeJS.Timeout>>>({})

useEffect(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 || []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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[]) => {
Expand Down Expand Up @@ -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()
Expand All @@ -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 => ({
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 <div className="h-dvh w-full bg-background" />
Expand Down Expand Up @@ -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 && (
<div className="flex items-center justify-center gap-3 px-4 py-2 bg-amber-950/80 border-b border-amber-800/60 text-amber-200 text-xs shrink-0">
<span className="opacity-80">⚡ AI enrichment requires an <strong className="text-amber-200">OpenRouter API key</strong> — use a free model (no credits needed) or add credits for GPT-4o, Claude, and more. Configure in the <strong className="text-amber-200">☰ left panel</strong>.</span>
<div className="flex items-center gap-2 shrink-0">
Expand Down Expand Up @@ -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}
Expand All @@ -1152,6 +1184,7 @@ export default function Page() {
<KanbanArea
key={`kanban-${activeProjectId}`}
blocks={activeProject.blocks}
aiEnabled={aiEnabled}
onDelete={deleteBlock}
onEdit={editBlock}
onEditAnnotation={editAnnotation}
Expand All @@ -1167,7 +1200,7 @@ export default function Page() {
<GraphArea
key={`graph-${activeProjectId}`}
blocks={activeProject.blocks}
ghostNote={ghostNotes[ghostNotes.length - 1]}
ghostNote={aiEnabled ? ghostNotes[ghostNotes.length - 1] : undefined}
projectName={activeProject.name}
onReEnrich={reEnrichBlock}
onChangeType={handleChangeType}
Expand Down Expand Up @@ -1221,9 +1254,9 @@ export default function Page() {
/>
</div>

<TileIndex
blocks={blocks}
onHighlight={setHighlightedBlockId}
<TileIndex
blocks={blocks}
onHighlight={setHighlightedBlockId}
highlightedId={highlightedBlockId}
onClose={() => setIsIndexOpen(false)}
isOpen={isIndexOpen}
Expand Down
3 changes: 3 additions & 0 deletions components/kanban-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@ interface KanbanAreaProps {

export function KanbanArea({
blocks,
aiEnabled,
onDelete,
onEdit,
onEditAnnotation,
Expand Down Expand Up @@ -170,6 +172,7 @@ export function KanbanArea({
onConnectionLock={handleConnectionLock}
isConnectionLocked={lockedConnectionId === block.id}
allBlocks={blocks}
aiEnabled={aiEnabled}
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onMoveToWorkspace={onMoveToWorkspace}
Expand Down
59 changes: 47 additions & 12 deletions components/project-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Eye,
EyeOff,
Save,
Sparkles,
FolderInput,
} from "lucide-react"
import {
Expand Down Expand Up @@ -355,6 +356,29 @@ export function ProjectSidebar({
</div>
</div>

{/* AI Enabled */}
<div className="flex items-start justify-between gap-3 rounded-md border border-white/5 bg-white/[0.02] px-2.5 py-2.5">
<div className="flex items-start gap-2">
<Sparkles className="h-3.5 w-3.5 mt-0.5 text-primary/60 shrink-0" />
<div>
<div className="font-mono text-[11px] font-bold text-foreground">AI Features</div>
<div className="font-mono text-[9px] text-muted-foreground mt-0.5 leading-relaxed">
Toggle all AI enrichment and synthesis features.
</div>
</div>
</div>
<button
onClick={() => 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"
}`}
>
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all duration-200 ${
draft.aiEnabled ? "left-5" : "left-0.5"
}`} />
</button>
</div>

{/* API Key */}
<div className="flex flex-col gap-2">
<label className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
Expand Down Expand Up @@ -553,18 +577,29 @@ export function ProjectSidebar({
</div>
)}

{/* API Status */}
<div className={`flex items-center gap-2 rounded-md px-2.5 py-2 font-mono text-[9px] ${draft.apiKey
? "bg-primary/10 border border-primary/20 text-primary"
: "bg-white/5 border border-white/5 text-muted-foreground"
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${draft.apiKey ? "bg-primary animate-pulse" : "bg-white/30"}`} />
{draft.apiKey ? `${currentPreset.label} — API key configured` : "No API key — AI disabled"}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* API Status */}
<div
className={`flex items-center gap-2 rounded-md px-2.5 py-2 font-mono text-[9px] ${
draft.aiEnabled && draft.apiKey
? "bg-primary/10 border border-primary/20 text-primary"
: "bg-white/5 border border-white/5 text-muted-foreground"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
draft.aiEnabled && draft.apiKey
? "bg-primary animate-pulse"
: "bg-white/30"
}`}
/>
{draft.aiEnabled
? (
draft.apiKey
? `${currentPreset.label} — API key configured`
: "No API key — AI disabled"
)
: "AI disabled"}
</div>

{/* Footer */}
<div className="p-3 border-t border-white/5 bg-black/10 shrink-0">
Expand Down
Loading