feat: enhance navigation in the app with back/forward buttons and view state management

This commit is contained in:
tusharmagar 2026-02-09 23:24:37 +05:30
parent 4046ba9d72
commit 14dab23670
3 changed files with 305 additions and 152 deletions

View file

@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css' import './App.css'
import z from 'zod'; import z from 'zod';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X } from 'lucide-react'; import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor'; import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } from './components/chat-button'; import { ChatInputBar } from './components/chat-button';
@ -446,8 +446,33 @@ function ChatInputWithMentions({
) )
} }
/** Traffic light placeholders + toggle button, fixed next to macOS traffic lights */ /** A snapshot of which view the user is on */
function FixedSidebarToggle() { type ViewState =
| { type: 'chat'; runId: string | null }
| { type: 'file'; path: string }
| { type: 'graph' }
| { type: 'task'; name: string }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId
if (a.type === 'file' && b.type === 'file') return a.path === b.path
if (a.type === 'task' && b.type === 'task') return a.name === b.name
return true // both graph
}
/** Traffic light placeholders + toggle button + back/forward nav, fixed next to macOS traffic lights */
function FixedSidebarToggle({
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: {
onNavigateBack: () => void
onNavigateForward: () => void
canNavigateBack: boolean
canNavigateForward: boolean
}) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar()
return ( return (
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> <div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
@ -466,6 +491,25 @@ function FixedSidebarToggle() {
> >
<PanelLeftIcon className="size-4" /> <PanelLeftIcon className="size-4" />
</button> </button>
{/* Back / Forward navigation */}
<button
type="button"
onClick={onNavigateBack}
disabled={!canNavigateBack}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go back"
>
<ChevronLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={onNavigateForward}
disabled={!canNavigateForward}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go forward"
>
<ChevronRightIcon className="size-4" />
</button>
</div> </div>
) )
} }
@ -489,8 +533,6 @@ function ContentHeader({ children }: { children: React.ReactNode }) {
function App() { function App() {
// File browser state (for Knowledge section) // File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null) const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileHistoryBack, setFileHistoryBack] = useState<string[]>([])
const [fileHistoryForward, setFileHistoryForward] = useState<string[]>([])
const [fileContent, setFileContent] = useState<string>('') const [fileContent, setFileContent] = useState<string>('')
const [editorContent, setEditorContent] = useState<string>('') const [editorContent, setEditorContent] = useState<string>('')
const [tree, setTree] = useState<TreeNode[]>([]) const [tree, setTree] = useState<TreeNode[]>([])
@ -506,6 +548,16 @@ function App() {
const [graphError, setGraphError] = useState<string | null>(null) const [graphError, setGraphError] = useState<string | null>(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
// Keep the latest selected path in a ref (avoids stale async updates when switching rapidly)
const selectedPathRef = useRef<string | null>(null)
const editorPathRef = useRef<string | null>(null)
const fileLoadRequestIdRef = useRef(0)
const initialContentByPathRef = useRef<Map<string, string>>(new Map())
// Global navigation history (back/forward) across views (chat/file/graph/task)
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
const [viewHistory, setViewHistory] = useState(historyRef.current)
// Auto-save state // Auto-save state
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null) const [lastSaved, setLastSaved] = useState<Date | null>(null)
@ -521,6 +573,7 @@ function App() {
const [, setModelUsage] = useState<LanguageModelUsage | null>(null) const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
const [runId, setRunId] = useState<string | null>(null) const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null) const runIdRef = useRef<string | null>(null)
const loadRunRequestIdRef = useRef(0)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [isStopping, setIsStopping] = useState(false) const [isStopping, setIsStopping] = useState(false)
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null) const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
@ -561,11 +614,28 @@ function App() {
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([]) const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([])
const [selectedBackgroundTask, setSelectedBackgroundTask] = useState<string | null>(null) const [selectedBackgroundTask, setSelectedBackgroundTask] = useState<string | null>(null)
// Keep selectedPathRef in sync for async guards
useEffect(() => {
selectedPathRef.current = selectedPath
if (!selectedPath) {
editorPathRef.current = null
}
}, [selectedPath])
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => { useEffect(() => {
runIdRef.current = runId runIdRef.current = runId
}, [runId]) }, [runId])
const handleEditorChange = useCallback((markdown: string) => {
const nextSelectedPath = selectedPathRef.current
// Avoid clobbering editorPath during rapid transitions (e.g. autosave rename) where refs may lag a tick.
if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) {
editorPathRef.current = nextSelectedPath
}
setEditorContent(markdown)
}, [])
// Load directory tree // Load directory tree
const loadDirectory = useCallback(async () => { const loadDirectory = useCallback(async () => {
try { try {
@ -600,16 +670,21 @@ function App() {
// Reload current file if it was changed externally // Reload current file if it was changed externally
if (!selectedPath) return if (!selectedPath) return
const pathToReload = selectedPath
const isCurrentFileChanged = const isCurrentFileChanged =
changedPath === selectedPath || changedPaths.includes(selectedPath) changedPath === pathToReload || changedPaths.includes(pathToReload)
if (isCurrentFileChanged) { if (isCurrentFileChanged) {
// Only reload if no unsaved edits // Only reload if no unsaved edits
if (editorContent === initialContentRef.current) { const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current
const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) if (editorContent === baseline) {
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
if (selectedPathRef.current !== pathToReload) return
setFileContent(result.data) setFileContent(result.data)
setEditorContent(result.data) setEditorContent(result.data)
editorPathRef.current = pathToReload
initialContentByPathRef.current.set(pathToReload, result.data)
initialContentRef.current = result.data initialContentRef.current = result.data
} }
} }
@ -627,13 +702,20 @@ function App() {
setLastSaved(null) setLastSaved(null)
return return
} }
(async () => { const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
let cancelled = false
;(async () => {
try { try {
const stat = await window.ipc.invoke('workspace:stat', { path: selectedPath }) const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad })
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
if (stat.kind === 'file') { if (stat.kind === 'file') {
const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
setFileContent(result.data) setFileContent(result.data)
setEditorContent(result.data) setEditorContent(result.data)
editorPathRef.current = pathToLoad
initialContentByPathRef.current.set(pathToLoad, result.data)
initialContentRef.current = result.data initialContentRef.current = result.data
setLastSaved(null) setLastSaved(null)
} else { } else {
@ -643,11 +725,16 @@ function App() {
} }
} catch (err) { } catch (err) {
console.error('Failed to load file:', err) console.error('Failed to load file:', err)
setFileContent('') if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) {
setEditorContent('') setFileContent('')
initialContentRef.current = '' setEditorContent('')
initialContentRef.current = ''
}
} }
})() })()
return () => {
cancelled = true
}
}, [selectedPath]) }, [selectedPath])
// Track recently opened markdown files for wiki links // Track recently opened markdown files for wiki links
@ -662,28 +749,42 @@ function App() {
// Auto-save when content changes // Auto-save when content changes
useEffect(() => { useEffect(() => {
if (!selectedPath || !selectedPath.endsWith('.md')) return const pathAtStart = editorPathRef.current
if (debouncedContent === initialContentRef.current) return if (!pathAtStart || !pathAtStart.endsWith('.md')) return
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
if (debouncedContent === baseline) return
if (!debouncedContent) return if (!debouncedContent) return
const saveFile = async () => { const saveFile = async () => {
setIsSaving(true) const wasActiveAtStart = selectedPathRef.current === pathAtStart
let pathToSave = selectedPath if (wasActiveAtStart) setIsSaving(true)
let pathToSave = pathAtStart
try { try {
if (!renameInProgressRef.current && selectedPath.startsWith('knowledge/')) { // Only rename the currently active file (avoids renaming/jumping while user switches rapidly)
if (
wasActiveAtStart &&
selectedPathRef.current === pathAtStart &&
!renameInProgressRef.current &&
pathAtStart.startsWith('knowledge/')
) {
const headingTitle = getHeadingTitle(debouncedContent) const headingTitle = getHeadingTitle(debouncedContent)
const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
const currentBase = getBaseName(selectedPath) const currentBase = getBaseName(pathAtStart)
if (desiredName && desiredName !== currentBase) { if (desiredName && desiredName !== currentBase) {
const parentDir = selectedPath.split('/').slice(0, -1).join('/') const parentDir = pathAtStart.split('/').slice(0, -1).join('/')
const targetPath = `${parentDir}/${desiredName}.md` const targetPath = `${parentDir}/${desiredName}.md`
if (targetPath !== selectedPath) { if (targetPath !== pathAtStart) {
const exists = await window.ipc.invoke('workspace:exists', { path: targetPath }) const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
if (!exists.exists) { if (!exists.exists) {
renameInProgressRef.current = true renameInProgressRef.current = true
await window.ipc.invoke('workspace:rename', { from: selectedPath, to: targetPath }) await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
pathToSave = targetPath pathToSave = targetPath
setSelectedPath(targetPath) editorPathRef.current = targetPath
initialContentByPathRef.current.delete(pathAtStart)
if (selectedPathRef.current === pathAtStart) {
setSelectedPath(targetPath)
}
} }
} }
} }
@ -693,17 +794,24 @@ function App() {
data: debouncedContent, data: debouncedContent,
opts: { encoding: 'utf8' } opts: { encoding: 'utf8' }
}) })
initialContentRef.current = debouncedContent initialContentByPathRef.current.set(pathToSave, debouncedContent)
setLastSaved(new Date())
// Only update "current file" UI state if we're still on this file
if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
initialContentRef.current = debouncedContent
setLastSaved(new Date())
}
} catch (err) { } catch (err) {
console.error('Failed to save file:', err) console.error('Failed to save file:', err)
} finally { } finally {
renameInProgressRef.current = false renameInProgressRef.current = false
setIsSaving(false) if (wasActiveAtStart && (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave)) {
setIsSaving(false)
}
} }
} }
saveFile() saveFile()
}, [debouncedContent, selectedPath]) }, [debouncedContent])
// Load runs list (all pages) // Load runs list (all pages)
const loadRuns = useCallback(async () => { const loadRuns = useCallback(async () => {
@ -790,8 +898,10 @@ function App() {
// Load a specific run and populate conversation // Load a specific run and populate conversation
const loadRun = useCallback(async (id: string) => { const loadRun = useCallback(async (id: string) => {
const requestId = (loadRunRequestIdRef.current += 1)
try { try {
const run = await window.ipc.invoke('runs:fetch', { runId: id }) const run = await window.ipc.invoke('runs:fetch', { runId: id })
if (loadRunRequestIdRef.current !== requestId) return
// Parse the log events into conversation items // Parse the log events into conversation items
const items: ConversationItem[] = [] const items: ConversationItem[] = []
@ -875,6 +985,7 @@ function App() {
} }
} }
} }
if (loadRunRequestIdRef.current !== requestId) return
// Track permission requests and responses from history // Track permission requests and responses from history
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>() const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
@ -893,6 +1004,7 @@ function App() {
respondedAskHumanIds.add(event.toolCallId) respondedAskHumanIds.add(event.toolCallId)
} }
} }
if (loadRunRequestIdRef.current !== requestId) return
// Separate pending vs responded permission requests // Separate pending vs responded permission requests
const pendingPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>() const pendingPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
@ -908,6 +1020,7 @@ function App() {
pendingAsks.set(id, req) pendingAsks.set(id, req)
} }
} }
if (loadRunRequestIdRef.current !== requestId) return
// Set the conversation and runId // Set the conversation and runId
setConversation(items) setConversation(items)
@ -1283,6 +1396,8 @@ function App() {
}, [runId]) }, [runId])
const handleNewChat = useCallback(() => { const handleNewChat = useCallback(() => {
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
loadRunRequestIdRef.current += 1
setConversation([]) setConversation([])
setCurrentAssistantMessage('') setCurrentAssistantMessage('')
setCurrentReasoning('') setCurrentReasoning('')
@ -1327,54 +1442,139 @@ function App() {
} }
}, [expandedFrom]) }, [expandedFrom])
// File navigation with history tracking const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
const navigateToFile = useCallback((path: string | null) => { historyRef.current = next
if (path === selectedPath) return setViewHistory(next)
}, [])
// Push current path to back history (if we have one) const currentViewState = React.useMemo<ViewState>(() => {
if (selectedPath) { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
setFileHistoryBack(prev => [...prev, selectedPath]) if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
if (last && viewStatesEqual(last, entry)) return stack
return [...stack, entry]
}, [])
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
setSelectedBackgroundTask(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedPath(view.path)
return
case 'graph':
setSelectedBackgroundTask(null)
setSelectedPath(null)
setExpandedFrom(null)
setIsGraphOpen(true)
return
case 'task':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(view.name)
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
if (view.runId) {
await loadRun(view.runId)
} else {
handleNewChat()
}
return
} }
// Clear forward history when navigating to a new file }, [handleNewChat, loadRun])
setFileHistoryForward([])
setSelectedPath(path)
// Clear background task selection when navigating to a file
setSelectedBackgroundTask(null)
setExpandedFrom(null)
}, [selectedPath])
const navigateBack = useCallback(() => { const navigateToView = useCallback(async (nextView: ViewState) => {
if (fileHistoryBack.length === 0) return const current = currentViewState
if (viewStatesEqual(current, nextView)) return
const newBack = [...fileHistoryBack] const nextHistory = {
const previousPath = newBack.pop()! back: appendUnique(historyRef.current.back, current),
forward: [] as ViewState[],
}
setHistory(nextHistory)
await applyViewState(nextView)
}, [appendUnique, applyViewState, currentViewState, setHistory])
// Push current path to forward history const navigateBack = useCallback(async () => {
if (selectedPath) { const { back, forward } = historyRef.current
setFileHistoryForward(prev => [...prev, selectedPath]) if (back.length === 0) return
let i = back.length - 1
while (i >= 0 && viewStatesEqual(back[i], currentViewState)) i -= 1
if (i < 0) {
setHistory({ back: [], forward })
return
} }
setFileHistoryBack(newBack) const target = back[i]
setSelectedPath(previousPath) const nextHistory = {
}, [fileHistoryBack, selectedPath]) back: back.slice(0, i),
forward: appendUnique(forward, currentViewState),
}
setHistory(nextHistory)
await applyViewState(target)
}, [appendUnique, applyViewState, currentViewState, setHistory])
const navigateForward = useCallback(() => { const navigateForward = useCallback(async () => {
if (fileHistoryForward.length === 0) return const { back, forward } = historyRef.current
if (forward.length === 0) return
const newForward = [...fileHistoryForward] let i = forward.length - 1
const nextPath = newForward.pop()! while (i >= 0 && viewStatesEqual(forward[i], currentViewState)) i -= 1
if (i < 0) {
// Push current path to back history setHistory({ back, forward: [] })
if (selectedPath) { return
setFileHistoryBack(prev => [...prev, selectedPath])
} }
setFileHistoryForward(newForward) const target = forward[i]
setSelectedPath(nextPath) const nextHistory = {
}, [fileHistoryForward, selectedPath]) back: appendUnique(back, currentViewState),
forward: forward.slice(0, i),
}
setHistory(nextHistory)
await applyViewState(target)
}, [appendUnique, applyViewState, currentViewState, setHistory])
const canNavigateBack = fileHistoryBack.length > 0 const canNavigateBack = React.useMemo(() => {
const canNavigateForward = fileHistoryForward.length > 0 for (let i = viewHistory.back.length - 1; i >= 0; i--) {
if (!viewStatesEqual(viewHistory.back[i], currentViewState)) return true
}
return false
}, [viewHistory.back, currentViewState])
const canNavigateForward = React.useMemo(() => {
for (let i = viewHistory.forward.length - 1; i >= 0; i--) {
if (!viewStatesEqual(viewHistory.forward[i], currentViewState)) return true
}
return false
}, [viewHistory.forward, currentViewState])
const navigateToFile = useCallback((path: string) => {
void navigateToView({ type: 'file', path })
}, [navigateToView])
const navigateToFullScreenChat = useCallback(() => {
// Only treat this as navigation when coming from another view
if (currentViewState.type !== 'chat') {
const nextHistory = {
back: appendUnique(historyRef.current.back, currentViewState),
forward: [] as ViewState[],
}
setHistory(nextHistory)
}
handleOpenFullScreenChat()
}, [appendUnique, currentViewState, handleOpenFullScreenChat, setHistory])
// Handle image upload for the markdown editor // Handle image upload for the markdown editor
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => { const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
@ -1424,18 +1624,17 @@ function App() {
if (isFullScreenChat && expandedFrom) { if (isFullScreenChat && expandedFrom) {
handleCloseFullScreenChat() handleCloseFullScreenChat()
} else { } else {
handleOpenFullScreenChat() navigateToFullScreenChat()
} }
} }
} }
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleOpenFullScreenChat, handleCloseFullScreenChat, isFullScreenChat, expandedFrom]) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
const toggleExpand = (path: string, kind: 'file' | 'dir') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { if (kind === 'file') {
navigateToFile(path) navigateToFile(path)
setIsGraphOpen(false)
return return
} }
@ -1451,10 +1650,12 @@ function App() {
// Handle sidebar section changes - switch to chat view for tasks // Handle sidebar section changes - switch to chat view for tasks
const handleSectionChange = useCallback((section: ActiveSection) => { const handleSectionChange = useCallback((section: ActiveSection) => {
if (section === 'tasks') { if (section === 'tasks') {
setSelectedPath(null) if (selectedBackgroundTask) return
setIsGraphOpen(false) if (selectedPath || isGraphOpen) {
void navigateToView({ type: 'chat', runId })
}
} }
}, []) }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
// Knowledge quick actions // Knowledge quick actions
const knowledgeFiles = React.useMemo(() => { const knowledgeFiles = React.useMemo(() => {
@ -1542,8 +1743,7 @@ function App() {
data: `# ${name}\n\n`, data: `# ${name}\n\n`,
opts: { encoding: 'utf8' } opts: { encoding: 'utf8' }
}) })
setIsGraphOpen(false) navigateToFile(fullPath)
setSelectedPath(fullPath)
} catch (err) { } catch (err) {
console.error('Failed to create note:', err) console.error('Failed to create note:', err)
throw err throw err
@ -1561,8 +1761,7 @@ function App() {
} }
}, },
openGraph: () => { openGraph: () => {
setSelectedPath(null) void navigateToView({ type: 'graph' })
setIsGraphOpen(true)
}, },
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
collapseAll: () => setExpandedPaths(new Set()), collapseAll: () => setExpandedPaths(new Set()),
@ -1593,7 +1792,7 @@ function App() {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath) navigator.clipboard.writeText(fullPath)
}, },
}), [tree, selectedPath, workspaceRoot, collectDirPaths]) }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView])
// Handler for when a voice note is created/updated // Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => { const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@ -1614,9 +1813,8 @@ function App() {
}) })
// Select the file to show it in the editor // Select the file to show it in the editor
setIsGraphOpen(false) navigateToFile(notePath)
setSelectedPath(notePath) }, [loadDirectory, navigateToFile])
}, [loadDirectory])
const ensureWikiFile = useCallback(async (wikiPath: string) => { const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath) const resolvedPath = toKnowledgePath(wikiPath)
@ -1870,15 +2068,14 @@ function App() {
runs={runs} runs={runs}
currentRunId={runId} currentRunId={runId}
tasksActions={{ tasksActions={{
onNewChat: handleNewChat, onNewChat: () => {
void navigateToView({ type: 'chat', runId: null })
},
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
setSelectedBackgroundTask(null) void navigateToView({ type: 'chat', runId: runIdToLoad })
loadRun(runIdToLoad)
}, },
onSelectBackgroundTask: (taskName) => { onSelectBackgroundTask: (taskName) => {
setSelectedBackgroundTask(taskName) void navigateToView({ type: 'task', name: taskName })
setSelectedPath(null)
setIsGraphOpen(false)
}, },
}} }}
backgroundTasks={backgroundTasks} backgroundTasks={backgroundTasks}
@ -1909,7 +2106,7 @@ function App() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setIsGraphOpen(false)} onClick={() => { void navigateToView({ type: 'chat', runId }) }}
className="titlebar-no-drag text-foreground" className="titlebar-no-drag text-foreground"
> >
Close Graph Close Graph
@ -1945,7 +2142,6 @@ function App() {
isLoading={graphStatus === 'loading'} isLoading={graphStatus === 'loading'}
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null} error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => { onSelectNode={(path) => {
setIsGraphOpen(false)
navigateToFile(path) navigateToFile(path)
}} }}
/> />
@ -1955,14 +2151,10 @@ function App() {
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<MarkdownEditor <MarkdownEditor
content={editorContent} content={editorContent}
onChange={setEditorContent} onChange={handleEditorChange}
placeholder="Start writing..." placeholder="Start writing..."
wikiLinks={wikiLinkConfig} wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
onNavigateBack={navigateBack}
onNavigateForward={navigateForward}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/> />
</div> </div>
) : ( ) : (
@ -1988,7 +2180,7 @@ function App() {
/> />
</div> </div>
) : ( ) : (
<FileCardProvider onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }}> <FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]"> <Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver /> <ScrollPositionPreserver />
@ -2093,7 +2285,7 @@ function App() {
defaultWidth={400} defaultWidth={400}
isOpen={isChatSidebarOpen} isOpen={isChatSidebarOpen}
onNewChat={handleNewChat} onNewChat={handleNewChat}
onOpenFullScreen={handleOpenFullScreenChat} onOpenFullScreen={navigateToFullScreenChat}
conversation={conversation} conversation={conversation}
currentAssistantMessage={currentAssistantMessage} currentAssistantMessage={currentAssistantMessage}
currentReasoning={currentReasoning} currentReasoning={currentReasoning}
@ -2113,11 +2305,16 @@ function App() {
permissionResponses={permissionResponses} permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse} onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse} onAskHumanResponse={handleAskHumanResponse}
onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }} onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
/> />
)} )}
{/* Rendered last so its no-drag region paints over the sidebar drag region */} {/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle /> <FixedSidebarToggle
onNavigateBack={() => { void navigateBack() }}
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/>
</SidebarProvider> </SidebarProvider>
{/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */} {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */}

View file

@ -22,8 +22,6 @@ import {
MinusIcon, MinusIcon,
LinkIcon, LinkIcon,
CodeSquareIcon, CodeSquareIcon,
ChevronLeftIcon,
ChevronRightIcon,
ExternalLinkIcon, ExternalLinkIcon,
Trash2Icon, Trash2Icon,
ImageIcon, ImageIcon,
@ -33,20 +31,12 @@ interface EditorToolbarProps {
editor: Editor | null editor: Editor | null
onSelectionHighlight?: (range: { from: number; to: number } | null) => void onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void onImageUpload?: (file: File) => Promise<void> | void
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
} }
export function EditorToolbar({ export function EditorToolbar({
editor, editor,
onSelectionHighlight, onSelectionHighlight,
onImageUpload, onImageUpload,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: EditorToolbarProps) { }: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
@ -117,35 +107,13 @@ export function EditorToolbar({
return ( return (
<div className="editor-toolbar"> <div className="editor-toolbar">
{/* Back / Forward Navigation */}
<Button
variant="ghost"
size="icon-sm"
onClick={onNavigateBack}
disabled={!canNavigateBack}
title="Go back"
>
<ChevronLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onNavigateForward}
disabled={!canNavigateForward}
title="Go forward"
>
<ChevronRightIcon className="size-4" />
</Button>
<div className="separator" />
{/* Text formatting */} {/* Text formatting */}
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold') || undefined} data-active={editor.isActive('bold') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Bold (Ctrl+B)" title="Bold (Ctrl+B)"
> >
<BoldIcon className="size-4" /> <BoldIcon className="size-4" />
@ -155,7 +123,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic') || undefined} data-active={editor.isActive('italic') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Italic (Ctrl+I)" title="Italic (Ctrl+I)"
> >
<ItalicIcon className="size-4" /> <ItalicIcon className="size-4" />
@ -165,7 +133,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
data-active={editor.isActive('strike') || undefined} data-active={editor.isActive('strike') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Strikethrough" title="Strikethrough"
> >
<StrikethroughIcon className="size-4" /> <StrikethroughIcon className="size-4" />
@ -175,7 +143,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleCode().run()} onClick={() => editor.chain().focus().toggleCode().run()}
data-active={editor.isActive('code') || undefined} data-active={editor.isActive('code') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Inline Code" title="Inline Code"
> >
<CodeIcon className="size-4" /> <CodeIcon className="size-4" />
@ -189,7 +157,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
data-active={editor.isActive('heading', { level: 1 }) || undefined} data-active={editor.isActive('heading', { level: 1 }) || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Heading 1" title="Heading 1"
> >
<Heading1Icon className="size-4" /> <Heading1Icon className="size-4" />
@ -199,7 +167,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
data-active={editor.isActive('heading', { level: 2 }) || undefined} data-active={editor.isActive('heading', { level: 2 }) || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Heading 2" title="Heading 2"
> >
<Heading2Icon className="size-4" /> <Heading2Icon className="size-4" />
@ -209,7 +177,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
data-active={editor.isActive('heading', { level: 3 }) || undefined} data-active={editor.isActive('heading', { level: 3 }) || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Heading 3" title="Heading 3"
> >
<Heading3Icon className="size-4" /> <Heading3Icon className="size-4" />
@ -223,7 +191,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
data-active={editor.isActive('bulletList') || undefined} data-active={editor.isActive('bulletList') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Bullet List" title="Bullet List"
> >
<ListIcon className="size-4" /> <ListIcon className="size-4" />
@ -233,7 +201,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
data-active={editor.isActive('orderedList') || undefined} data-active={editor.isActive('orderedList') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Ordered List" title="Ordered List"
> >
<ListOrderedIcon className="size-4" /> <ListOrderedIcon className="size-4" />
@ -243,7 +211,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleTaskList().run()} onClick={() => editor.chain().focus().toggleTaskList().run()}
data-active={editor.isActive('taskList') || undefined} data-active={editor.isActive('taskList') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Task List" title="Task List"
> >
<ListTodoIcon className="size-4" /> <ListTodoIcon className="size-4" />
@ -257,7 +225,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
data-active={editor.isActive('blockquote') || undefined} data-active={editor.isActive('blockquote') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Blockquote" title="Blockquote"
> >
<QuoteIcon className="size-4" /> <QuoteIcon className="size-4" />
@ -267,7 +235,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
data-active={editor.isActive('codeBlock') || undefined} data-active={editor.isActive('codeBlock') || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Code Block" title="Code Block"
> >
<CodeSquareIcon className="size-4" /> <CodeSquareIcon className="size-4" />
@ -296,7 +264,7 @@ export function EditorToolbar({
size="icon-sm" size="icon-sm"
onClick={openLinkPopover} onClick={openLinkPopover}
data-active={isLinkActive || undefined} data-active={isLinkActive || undefined}
className="data-[active]:bg-accent" className="data-active:bg-accent"
title="Link" title="Link"
> >
<LinkIcon className="size-4" /> <LinkIcon className="size-4" />

View file

@ -183,10 +183,6 @@ interface MarkdownEditorProps {
placeholder?: string placeholder?: string
wikiLinks?: WikiLinkConfig wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null> onImageUpload?: (file: File) => Promise<string | null>
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
} }
type WikiLinkMatch = { type WikiLinkMatch = {
@ -235,10 +231,6 @@ export function MarkdownEditor({
placeholder = 'Start writing...', placeholder = 'Start writing...',
wikiLinks, wikiLinks,
onImageUpload, onImageUpload,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@ -488,10 +480,6 @@ export function MarkdownEditor({
editor={editor} editor={editor}
onSelectionHighlight={setSelectionHighlight} onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder} onImageUpload={handleImageUploadWithPlaceholder}
onNavigateBack={onNavigateBack}
onNavigateForward={onNavigateForward}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/> />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}> <div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />