mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
feat: enhance navigation in the app with back/forward buttons and view state management
This commit is contained in:
parent
4046ba9d72
commit
14dab23670
3 changed files with 305 additions and 152 deletions
|
|
@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
|||
import './App.css'
|
||||
import z from 'zod';
|
||||
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 { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatInputBar } from './components/chat-button';
|
||||
|
|
@ -446,8 +446,33 @@ function ChatInputWithMentions({
|
|||
)
|
||||
}
|
||||
|
||||
/** Traffic light placeholders + toggle button, fixed next to macOS traffic lights */
|
||||
function FixedSidebarToggle() {
|
||||
/** A snapshot of which view the user is on */
|
||||
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()
|
||||
return (
|
||||
<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" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -489,8 +533,6 @@ function ContentHeader({ children }: { children: React.ReactNode }) {
|
|||
function App() {
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileHistoryBack, setFileHistoryBack] = useState<string[]>([])
|
||||
const [fileHistoryForward, setFileHistoryForward] = useState<string[]>([])
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
const [editorContent, setEditorContent] = useState<string>('')
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
|
|
@ -506,6 +548,16 @@ function App() {
|
|||
const [graphError, setGraphError] = useState<string | null>(null)
|
||||
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
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
|
|
@ -521,6 +573,7 @@ function App() {
|
|||
const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
|
||||
const [runId, setRunId] = useState<string | null>(null)
|
||||
const runIdRef = useRef<string | null>(null)
|
||||
const loadRunRequestIdRef = useRef(0)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
|
||||
|
|
@ -561,11 +614,28 @@ function App() {
|
|||
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([])
|
||||
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)
|
||||
useEffect(() => {
|
||||
runIdRef.current = 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
|
||||
const loadDirectory = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -600,16 +670,21 @@ function App() {
|
|||
|
||||
// Reload current file if it was changed externally
|
||||
if (!selectedPath) return
|
||||
const pathToReload = selectedPath
|
||||
|
||||
const isCurrentFileChanged =
|
||||
changedPath === selectedPath || changedPaths.includes(selectedPath)
|
||||
changedPath === pathToReload || changedPaths.includes(pathToReload)
|
||||
|
||||
if (isCurrentFileChanged) {
|
||||
// Only reload if no unsaved edits
|
||||
if (editorContent === initialContentRef.current) {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath })
|
||||
const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current
|
||||
if (editorContent === baseline) {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
|
||||
if (selectedPathRef.current !== pathToReload) return
|
||||
setFileContent(result.data)
|
||||
setEditorContent(result.data)
|
||||
editorPathRef.current = pathToReload
|
||||
initialContentByPathRef.current.set(pathToReload, result.data)
|
||||
initialContentRef.current = result.data
|
||||
}
|
||||
}
|
||||
|
|
@ -627,13 +702,20 @@ function App() {
|
|||
setLastSaved(null)
|
||||
return
|
||||
}
|
||||
(async () => {
|
||||
const requestId = (fileLoadRequestIdRef.current += 1)
|
||||
const pathToLoad = selectedPath
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
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') {
|
||||
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)
|
||||
setEditorContent(result.data)
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, result.data)
|
||||
initialContentRef.current = result.data
|
||||
setLastSaved(null)
|
||||
} else {
|
||||
|
|
@ -643,11 +725,16 @@ function App() {
|
|||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file:', err)
|
||||
setFileContent('')
|
||||
setEditorContent('')
|
||||
initialContentRef.current = ''
|
||||
if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) {
|
||||
setFileContent('')
|
||||
setEditorContent('')
|
||||
initialContentRef.current = ''
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedPath])
|
||||
|
||||
// Track recently opened markdown files for wiki links
|
||||
|
|
@ -662,28 +749,42 @@ function App() {
|
|||
|
||||
// Auto-save when content changes
|
||||
useEffect(() => {
|
||||
if (!selectedPath || !selectedPath.endsWith('.md')) return
|
||||
if (debouncedContent === initialContentRef.current) return
|
||||
const pathAtStart = editorPathRef.current
|
||||
if (!pathAtStart || !pathAtStart.endsWith('.md')) return
|
||||
|
||||
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
|
||||
if (debouncedContent === baseline) return
|
||||
if (!debouncedContent) return
|
||||
|
||||
const saveFile = async () => {
|
||||
setIsSaving(true)
|
||||
let pathToSave = selectedPath
|
||||
const wasActiveAtStart = selectedPathRef.current === pathAtStart
|
||||
if (wasActiveAtStart) setIsSaving(true)
|
||||
let pathToSave = pathAtStart
|
||||
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 desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
|
||||
const currentBase = getBaseName(selectedPath)
|
||||
const currentBase = getBaseName(pathAtStart)
|
||||
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`
|
||||
if (targetPath !== selectedPath) {
|
||||
if (targetPath !== pathAtStart) {
|
||||
const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
|
||||
if (!exists.exists) {
|
||||
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
|
||||
setSelectedPath(targetPath)
|
||||
editorPathRef.current = targetPath
|
||||
initialContentByPathRef.current.delete(pathAtStart)
|
||||
if (selectedPathRef.current === pathAtStart) {
|
||||
setSelectedPath(targetPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -693,17 +794,24 @@ function App() {
|
|||
data: debouncedContent,
|
||||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
initialContentRef.current = debouncedContent
|
||||
setLastSaved(new Date())
|
||||
initialContentByPathRef.current.set(pathToSave, debouncedContent)
|
||||
|
||||
// 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) {
|
||||
console.error('Failed to save file:', err)
|
||||
} finally {
|
||||
renameInProgressRef.current = false
|
||||
setIsSaving(false)
|
||||
if (wasActiveAtStart && (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave)) {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
saveFile()
|
||||
}, [debouncedContent, selectedPath])
|
||||
}, [debouncedContent])
|
||||
|
||||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
|
|
@ -790,8 +898,10 @@ function App() {
|
|||
|
||||
// Load a specific run and populate conversation
|
||||
const loadRun = useCallback(async (id: string) => {
|
||||
const requestId = (loadRunRequestIdRef.current += 1)
|
||||
try {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Parse the log events into conversation items
|
||||
const items: ConversationItem[] = []
|
||||
|
|
@ -875,6 +985,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Track permission requests and responses from history
|
||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
|
|
@ -893,6 +1004,7 @@ function App() {
|
|||
respondedAskHumanIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Separate pending vs responded permission requests
|
||||
const pendingPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
|
|
@ -908,6 +1020,7 @@ function App() {
|
|||
pendingAsks.set(id, req)
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Set the conversation and runId
|
||||
setConversation(items)
|
||||
|
|
@ -1283,6 +1396,8 @@ function App() {
|
|||
}, [runId])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
// Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in)
|
||||
loadRunRequestIdRef.current += 1
|
||||
setConversation([])
|
||||
setCurrentAssistantMessage('')
|
||||
setCurrentReasoning('')
|
||||
|
|
@ -1327,54 +1442,139 @@ function App() {
|
|||
}
|
||||
}, [expandedFrom])
|
||||
|
||||
// File navigation with history tracking
|
||||
const navigateToFile = useCallback((path: string | null) => {
|
||||
if (path === selectedPath) return
|
||||
const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
|
||||
historyRef.current = next
|
||||
setViewHistory(next)
|
||||
}, [])
|
||||
|
||||
// Push current path to back history (if we have one)
|
||||
if (selectedPath) {
|
||||
setFileHistoryBack(prev => [...prev, selectedPath])
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
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
|
||||
setFileHistoryForward([])
|
||||
setSelectedPath(path)
|
||||
// Clear background task selection when navigating to a file
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
}, [selectedPath])
|
||||
}, [handleNewChat, loadRun])
|
||||
|
||||
const navigateBack = useCallback(() => {
|
||||
if (fileHistoryBack.length === 0) return
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
if (viewStatesEqual(current, nextView)) return
|
||||
|
||||
const newBack = [...fileHistoryBack]
|
||||
const previousPath = newBack.pop()!
|
||||
const nextHistory = {
|
||||
back: appendUnique(historyRef.current.back, current),
|
||||
forward: [] as ViewState[],
|
||||
}
|
||||
setHistory(nextHistory)
|
||||
await applyViewState(nextView)
|
||||
}, [appendUnique, applyViewState, currentViewState, setHistory])
|
||||
|
||||
// Push current path to forward history
|
||||
if (selectedPath) {
|
||||
setFileHistoryForward(prev => [...prev, selectedPath])
|
||||
const navigateBack = useCallback(async () => {
|
||||
const { back, forward } = historyRef.current
|
||||
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)
|
||||
setSelectedPath(previousPath)
|
||||
}, [fileHistoryBack, selectedPath])
|
||||
const target = back[i]
|
||||
const nextHistory = {
|
||||
back: back.slice(0, i),
|
||||
forward: appendUnique(forward, currentViewState),
|
||||
}
|
||||
setHistory(nextHistory)
|
||||
await applyViewState(target)
|
||||
}, [appendUnique, applyViewState, currentViewState, setHistory])
|
||||
|
||||
const navigateForward = useCallback(() => {
|
||||
if (fileHistoryForward.length === 0) return
|
||||
const navigateForward = useCallback(async () => {
|
||||
const { back, forward } = historyRef.current
|
||||
if (forward.length === 0) return
|
||||
|
||||
const newForward = [...fileHistoryForward]
|
||||
const nextPath = newForward.pop()!
|
||||
|
||||
// Push current path to back history
|
||||
if (selectedPath) {
|
||||
setFileHistoryBack(prev => [...prev, selectedPath])
|
||||
let i = forward.length - 1
|
||||
while (i >= 0 && viewStatesEqual(forward[i], currentViewState)) i -= 1
|
||||
if (i < 0) {
|
||||
setHistory({ back, forward: [] })
|
||||
return
|
||||
}
|
||||
|
||||
setFileHistoryForward(newForward)
|
||||
setSelectedPath(nextPath)
|
||||
}, [fileHistoryForward, selectedPath])
|
||||
const target = forward[i]
|
||||
const nextHistory = {
|
||||
back: appendUnique(back, currentViewState),
|
||||
forward: forward.slice(0, i),
|
||||
}
|
||||
setHistory(nextHistory)
|
||||
await applyViewState(target)
|
||||
}, [appendUnique, applyViewState, currentViewState, setHistory])
|
||||
|
||||
const canNavigateBack = fileHistoryBack.length > 0
|
||||
const canNavigateForward = fileHistoryForward.length > 0
|
||||
const canNavigateBack = React.useMemo(() => {
|
||||
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
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
|
|
@ -1424,18 +1624,17 @@ function App() {
|
|||
if (isFullScreenChat && expandedFrom) {
|
||||
handleCloseFullScreenChat()
|
||||
} else {
|
||||
handleOpenFullScreenChat()
|
||||
navigateToFullScreenChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleOpenFullScreenChat, handleCloseFullScreenChat, isFullScreenChat, expandedFrom])
|
||||
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
navigateToFile(path)
|
||||
setIsGraphOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1451,10 +1650,12 @@ function App() {
|
|||
// Handle sidebar section changes - switch to chat view for tasks
|
||||
const handleSectionChange = useCallback((section: ActiveSection) => {
|
||||
if (section === 'tasks') {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
if (selectedBackgroundTask) return
|
||||
if (selectedPath || isGraphOpen) {
|
||||
void navigateToView({ type: 'chat', runId })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
|
||||
|
||||
// Knowledge quick actions
|
||||
const knowledgeFiles = React.useMemo(() => {
|
||||
|
|
@ -1542,8 +1743,7 @@ function App() {
|
|||
data: `# ${name}\n\n`,
|
||||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(fullPath)
|
||||
navigateToFile(fullPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
throw err
|
||||
|
|
@ -1561,8 +1761,7 @@ function App() {
|
|||
}
|
||||
},
|
||||
openGraph: () => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||
collapseAll: () => setExpandedPaths(new Set()),
|
||||
|
|
@ -1593,7 +1792,7 @@ function App() {
|
|||
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
|
||||
navigator.clipboard.writeText(fullPath)
|
||||
},
|
||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
|
||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView])
|
||||
|
||||
// Handler for when a voice note is created/updated
|
||||
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
|
||||
|
|
@ -1614,9 +1813,8 @@ function App() {
|
|||
})
|
||||
|
||||
// Select the file to show it in the editor
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(notePath)
|
||||
}, [loadDirectory])
|
||||
navigateToFile(notePath)
|
||||
}, [loadDirectory, navigateToFile])
|
||||
|
||||
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||
const resolvedPath = toKnowledgePath(wikiPath)
|
||||
|
|
@ -1870,15 +2068,14 @@ function App() {
|
|||
runs={runs}
|
||||
currentRunId={runId}
|
||||
tasksActions={{
|
||||
onNewChat: handleNewChat,
|
||||
onNewChat: () => {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
},
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
setSelectedBackgroundTask(null)
|
||||
loadRun(runIdToLoad)
|
||||
void navigateToView({ type: 'chat', runId: runIdToLoad })
|
||||
},
|
||||
onSelectBackgroundTask: (taskName) => {
|
||||
setSelectedBackgroundTask(taskName)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
void navigateToView({ type: 'task', name: taskName })
|
||||
},
|
||||
}}
|
||||
backgroundTasks={backgroundTasks}
|
||||
|
|
@ -1909,7 +2106,7 @@ function App() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsGraphOpen(false)}
|
||||
onClick={() => { void navigateToView({ type: 'chat', runId }) }}
|
||||
className="titlebar-no-drag text-foreground"
|
||||
>
|
||||
Close Graph
|
||||
|
|
@ -1945,7 +2142,6 @@ function App() {
|
|||
isLoading={graphStatus === 'loading'}
|
||||
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
|
||||
onSelectNode={(path) => {
|
||||
setIsGraphOpen(false)
|
||||
navigateToFile(path)
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1955,14 +2151,10 @@ function App() {
|
|||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<MarkdownEditor
|
||||
content={editorContent}
|
||||
onChange={setEditorContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
onNavigateBack={navigateBack}
|
||||
onNavigateForward={navigateForward}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1988,7 +2180,7 @@ function App() {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileCardProvider onOpenKnowledgeFile={(path) => { setSelectedPath(path); setIsGraphOpen(false) }}>
|
||||
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<ScrollPositionPreserver />
|
||||
|
|
@ -2093,7 +2285,7 @@ function App() {
|
|||
defaultWidth={400}
|
||||
isOpen={isChatSidebarOpen}
|
||||
onNewChat={handleNewChat}
|
||||
onOpenFullScreen={handleOpenFullScreenChat}
|
||||
onOpenFullScreen={navigateToFullScreenChat}
|
||||
conversation={conversation}
|
||||
currentAssistantMessage={currentAssistantMessage}
|
||||
currentReasoning={currentReasoning}
|
||||
|
|
@ -2113,11 +2305,16 @@ function App() {
|
|||
permissionResponses={permissionResponses}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
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 */}
|
||||
<FixedSidebarToggle />
|
||||
<FixedSidebarToggle
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
||||
{/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ import {
|
|||
MinusIcon,
|
||||
LinkIcon,
|
||||
CodeSquareIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
Trash2Icon,
|
||||
ImageIcon,
|
||||
|
|
@ -33,20 +31,12 @@ interface EditorToolbarProps {
|
|||
editor: Editor | null
|
||||
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
|
||||
onImageUpload?: (file: File) => Promise<void> | void
|
||||
onNavigateBack?: () => void
|
||||
onNavigateForward?: () => void
|
||||
canNavigateBack?: boolean
|
||||
canNavigateForward?: boolean
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
editor,
|
||||
onSelectionHighlight,
|
||||
onImageUpload,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
}: EditorToolbarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
|
||||
|
|
@ -117,35 +107,13 @@ export function EditorToolbar({
|
|||
|
||||
return (
|
||||
<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 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
data-active={editor.isActive('bold') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<BoldIcon className="size-4" />
|
||||
|
|
@ -155,7 +123,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
data-active={editor.isActive('italic') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<ItalicIcon className="size-4" />
|
||||
|
|
@ -165,7 +133,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
data-active={editor.isActive('strike') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Strikethrough"
|
||||
>
|
||||
<StrikethroughIcon className="size-4" />
|
||||
|
|
@ -175,7 +143,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
data-active={editor.isActive('code') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Inline Code"
|
||||
>
|
||||
<CodeIcon className="size-4" />
|
||||
|
|
@ -189,7 +157,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
data-active={editor.isActive('heading', { level: 1 }) || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1Icon className="size-4" />
|
||||
|
|
@ -199,7 +167,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
data-active={editor.isActive('heading', { level: 2 }) || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2Icon className="size-4" />
|
||||
|
|
@ -209,7 +177,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
data-active={editor.isActive('heading', { level: 3 }) || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3Icon className="size-4" />
|
||||
|
|
@ -223,7 +191,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
data-active={editor.isActive('bulletList') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Bullet List"
|
||||
>
|
||||
<ListIcon className="size-4" />
|
||||
|
|
@ -233,7 +201,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
data-active={editor.isActive('orderedList') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrderedIcon className="size-4" />
|
||||
|
|
@ -243,7 +211,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||
data-active={editor.isActive('taskList') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Task List"
|
||||
>
|
||||
<ListTodoIcon className="size-4" />
|
||||
|
|
@ -257,7 +225,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
data-active={editor.isActive('blockquote') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Blockquote"
|
||||
>
|
||||
<QuoteIcon className="size-4" />
|
||||
|
|
@ -267,7 +235,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
data-active={editor.isActive('codeBlock') || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Code Block"
|
||||
>
|
||||
<CodeSquareIcon className="size-4" />
|
||||
|
|
@ -296,7 +264,7 @@ export function EditorToolbar({
|
|||
size="icon-sm"
|
||||
onClick={openLinkPopover}
|
||||
data-active={isLinkActive || undefined}
|
||||
className="data-[active]:bg-accent"
|
||||
className="data-active:bg-accent"
|
||||
title="Link"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
|
|
|
|||
|
|
@ -183,10 +183,6 @@ interface MarkdownEditorProps {
|
|||
placeholder?: string
|
||||
wikiLinks?: WikiLinkConfig
|
||||
onImageUpload?: (file: File) => Promise<string | null>
|
||||
onNavigateBack?: () => void
|
||||
onNavigateForward?: () => void
|
||||
canNavigateBack?: boolean
|
||||
canNavigateForward?: boolean
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -235,10 +231,6 @@ export function MarkdownEditor({
|
|||
placeholder = 'Start writing...',
|
||||
wikiLinks,
|
||||
onImageUpload,
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -488,10 +480,6 @@ export function MarkdownEditor({
|
|||
editor={editor}
|
||||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
onNavigateBack={onNavigateBack}
|
||||
onNavigateForward={onNavigateForward}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
/>
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue