mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +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 './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 */}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue