mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
view more in knowledge
This commit is contained in:
parent
1b2ed1bc2e
commit
43537c5e20
4 changed files with 656 additions and 622 deletions
|
|
@ -26,6 +26,7 @@ import { LiveNotesView } from '@/components/live-notes-view';
|
|||
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||
import { EmailView } from '@/components/email-view';
|
||||
import { WorkspaceView } from '@/components/workspace-view';
|
||||
import { KnowledgeView } from '@/components/knowledge-view';
|
||||
import { MeetingsView } from '@/components/meetings-view';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
|
|
@ -185,6 +186,7 @@ const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
|
|||
const EMAIL_TAB_PATH = '__rowboat_email__'
|
||||
const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
|
||||
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||
const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
|
|
@ -319,6 +321,7 @@ const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
|
|||
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
|
||||
const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
|
||||
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
|
||||
const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
|
||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
|
|
@ -570,6 +573,7 @@ type ViewState =
|
|||
| { type: 'live-notes' }
|
||||
| { type: 'email' }
|
||||
| { type: 'workspace'; path?: string }
|
||||
| { type: 'knowledge-view' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
|
|
@ -624,6 +628,8 @@ function parseDeepLink(input: string): ViewState | null {
|
|||
const path = params.get('path')
|
||||
return { type: 'workspace', path: path ?? undefined }
|
||||
}
|
||||
case 'knowledge-view':
|
||||
return { type: 'knowledge-view' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
@ -761,6 +767,7 @@ function App() {
|
|||
const [isEmailOpen, setIsEmailOpen] = useState(false)
|
||||
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
|
||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{
|
||||
path: string | null
|
||||
graph: boolean
|
||||
|
|
@ -1098,6 +1105,7 @@ function App() {
|
|||
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
|
||||
if (isEmailTabPath(tab.path)) return 'Email'
|
||||
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
|
||||
if (isKnowledgeViewTabPath(tab.path)) return 'Knowledge'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -2810,7 +2818,7 @@ function App() {
|
|||
setActiveFileTabId(existingTab.id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedPath(path)
|
||||
return
|
||||
}
|
||||
|
|
@ -2819,7 +2827,7 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedPath(path)
|
||||
}, [fileTabs, dismissBrowserOverlay])
|
||||
|
||||
|
|
@ -2838,14 +2846,14 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
return
|
||||
}
|
||||
if (isLiveNotesTabPath(tab.path)) {
|
||||
|
|
@ -2856,6 +2864,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -2867,6 +2876,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsBgTasksOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -2879,6 +2889,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
return
|
||||
}
|
||||
if (isEmailTabPath(tab.path)) {
|
||||
|
|
@ -2889,6 +2900,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -2900,18 +2912,31 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsWorkspaceOpen(true)
|
||||
return
|
||||
}
|
||||
if (isKnowledgeViewTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(true)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
|
|
@ -2934,7 +2959,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2948,12 +2973,12 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2963,6 +2988,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2971,6 +2997,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
|
|
@ -2981,6 +3008,7 @@ function App() {
|
|||
setIsBgTasksOpen(true)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
} else if (isEmailTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2989,6 +3017,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
} else if (isWorkspaceTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
|
|
@ -2998,11 +3027,22 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsWorkspaceOpen(true)
|
||||
} else if (isKnowledgeViewTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -3033,7 +3073,7 @@ function App() {
|
|||
dismissBrowserOverlay()
|
||||
handleNewChat()
|
||||
// Left-pane "new chat" should always open full chat view.
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
|
|
@ -3050,8 +3090,8 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -3183,7 +3223,7 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// Remember where we came from so the close button can return
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
|
|
@ -3199,19 +3239,19 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, dismissBrowserOverlay])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, dismissBrowserOverlay])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
} else if (expandedFrom.meetings) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
|
|
@ -3243,7 +3283,7 @@ function App() {
|
|||
} else if (expandedFrom.path) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -3258,10 +3298,11 @@ function App() {
|
|||
if (isLiveNotesOpen) return { type: 'live-notes' }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
||||
if (isKnowledgeViewOpen) return { type: 'knowledge-view' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, workspaceInitialPath, runId])
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, workspaceInitialPath, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -3373,6 +3414,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const ensureKnowledgeViewFileTab = useCallback(() => {
|
||||
const existing = fileTabs.find((tab) => isKnowledgeViewTabPath(tab.path))
|
||||
if (existing) {
|
||||
setActiveFileTabId(existing.id)
|
||||
return
|
||||
}
|
||||
const id = newFileTabId()
|
||||
setFileTabs((prev) => [...prev, { id, path: KNOWLEDGE_VIEW_TAB_PATH }])
|
||||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const openEmailView = useCallback(() => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3382,6 +3434,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3394,7 +3447,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3412,6 +3465,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3427,7 +3481,7 @@ function App() {
|
|||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -3442,7 +3496,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -3455,7 +3509,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
|
|
@ -3468,7 +3522,7 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
return
|
||||
case 'meetings':
|
||||
|
|
@ -3484,6 +3538,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
ensureMeetingsFileTab()
|
||||
return
|
||||
case 'live-notes':
|
||||
|
|
@ -3498,6 +3553,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
ensureLiveNotesFileTab()
|
||||
return
|
||||
|
|
@ -3514,6 +3570,7 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
ensureEmailFileTab()
|
||||
return
|
||||
case 'workspace':
|
||||
|
|
@ -3529,9 +3586,26 @@ function App() {
|
|||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(true)
|
||||
setIsKnowledgeViewOpen(false)
|
||||
setWorkspaceInitialPath(view.path ?? null)
|
||||
ensureWorkspaceFileTab()
|
||||
return
|
||||
case 'knowledge-view':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsKnowledgeViewOpen(true)
|
||||
ensureKnowledgeViewFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3540,7 +3614,7 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -3548,7 +3622,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -3870,7 +3944,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3955,11 +4029,11 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && isChatSidebarOpen)
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && isChatSidebarOpen)
|
||||
const targetPane: ShortcutPane = rightPaneAvailable
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: 'left'
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen)
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen)
|
||||
const selectedKnowledgePath = isGraphOpen
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
|
|
@ -3974,6 +4048,8 @@ function App() {
|
|||
? EMAIL_TAB_PATH
|
||||
: isWorkspaceOpen
|
||||
? WORKSPACE_TAB_PATH
|
||||
: isKnowledgeViewOpen
|
||||
? KNOWLEDGE_VIEW_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
|
|
@ -4028,7 +4104,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('keydown', handleTabKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -4053,7 +4129,7 @@ function App() {
|
|||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -4179,26 +4255,33 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// From chat-only landing state, open graph directly in full knowledge view.
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
},
|
||||
openWorkspaceAt: (path?: string) => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'workspace', path })
|
||||
},
|
||||
openKnowledgeView: () => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'knowledge-view' })
|
||||
},
|
||||
createWorkspace: async (name: string): Promise<string> => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('Name is required')
|
||||
|
|
@ -4805,7 +4888,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4822,7 +4905,7 @@ function App() {
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen) {
|
||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen) {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
}}>
|
||||
|
|
@ -4836,18 +4919,8 @@ function App() {
|
|||
<SidebarContentPanel
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
onToggleFolder={(path) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) next.delete(path)
|
||||
else next.add(path)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
knowledgeActions={knowledgeActions}
|
||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||
runs={runs}
|
||||
currentRunId={runId}
|
||||
processingRunIds={processingRunIds}
|
||||
|
|
@ -4855,7 +4928,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4866,7 +4939,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4890,14 +4963,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4945,7 +5018,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4953,7 +5026,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -5006,7 +5079,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -5021,7 +5094,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -5036,7 +5109,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -5111,6 +5184,26 @@ function App() {
|
|||
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
|
||||
/>
|
||||
</div>
|
||||
) : isKnowledgeViewOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<KnowledgeView
|
||||
tree={tree}
|
||||
actions={{
|
||||
createNote: knowledgeActions.createNote,
|
||||
createFolder: knowledgeActions.createFolder,
|
||||
rename: knowledgeActions.rename,
|
||||
remove: knowledgeActions.remove,
|
||||
copyPath: knowledgeActions.copyPath,
|
||||
revealInFileManager: knowledgeActions.revealInFileManager,
|
||||
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
|
||||
}}
|
||||
onOpenNote={(path) => navigateToFile(path)}
|
||||
onOpenGraph={() => knowledgeActions.openGraph()}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
onOpenBases={() => knowledgeActions.openBases()}
|
||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||
/>
|
||||
</div>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
|
|||
|
|
@ -1,33 +1,11 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||
|
||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
||||
const CACHE_MAX_ENTRIES = 20
|
||||
|
||||
type CacheEntry = { html: string; mtimeMs: number; size: number }
|
||||
const htmlCache = new Map<string, CacheEntry>()
|
||||
|
||||
function getCached(path: string, mtimeMs: number, size: number): string | null {
|
||||
const entry = htmlCache.get(path)
|
||||
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
|
||||
// Refresh LRU position
|
||||
htmlCache.delete(path)
|
||||
htmlCache.set(path, entry)
|
||||
return entry.html
|
||||
}
|
||||
|
||||
function setCached(path: string, html: string, mtimeMs: number, size: number) {
|
||||
htmlCache.set(path, { html, mtimeMs, size })
|
||||
while (htmlCache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = htmlCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
htmlCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
type ViewerState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; html: string }
|
||||
| { kind: 'loaded' }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'tooLarge'; sizeMB: number }
|
||||
| { kind: 'error'; message: string }
|
||||
|
|
@ -36,9 +14,15 @@ interface HtmlFileViewerProps {
|
|||
path: string
|
||||
}
|
||||
|
||||
function toAppWorkspaceUrl(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
|
||||
return `app://workspace/${segments.join('/')}`
|
||||
}
|
||||
|
||||
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
||||
return
|
||||
}
|
||||
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
||||
if (cachedHtml !== null) {
|
||||
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
|
||||
return
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
if (cancelled) return
|
||||
setCached(path, result.data, stat.mtimeMs, stat.size)
|
||||
if (!result.data || result.data.trim() === '') {
|
||||
if (stat.size === 0) {
|
||||
setState({ kind: 'empty' })
|
||||
return
|
||||
}
|
||||
setState({ kind: 'loaded', html: result.data })
|
||||
setState({ kind: 'loaded' })
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -124,20 +100,16 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
|
||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
||||
// works fine; HTML that ships next to sibling assets will look broken.
|
||||
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
|
||||
// support; that path also resolves through the existing path-traversal
|
||||
// guard in resolveWorkspacePath.
|
||||
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
|
||||
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
|
||||
// to the file resolve correctly (the path-traversal guard in
|
||||
// resolveWorkspacePath already gates the protocol handler).
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{state.kind === 'loaded' && (
|
||||
<iframe
|
||||
key={path}
|
||||
srcDoc={state.html}
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
|
|
|
|||
405
apps/x/apps/renderer/src/components/knowledge-view.tsx
Normal file
405
apps/x/apps/renderer/src/components/knowledge-view.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
File as FileIcon,
|
||||
FilePlus,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Network,
|
||||
Pencil,
|
||||
SearchIcon,
|
||||
Table2,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { VoiceNoteButton } from '@/components/sidebar-content'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
export type KnowledgeViewActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => Promise<string>
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
revealInFileManager: (path: string, isDir: boolean) => void
|
||||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
type KnowledgeViewProps = {
|
||||
tree: TreeNode[]
|
||||
actions: KnowledgeViewActions
|
||||
onOpenNote: (path: string) => void
|
||||
onOpenGraph: () => void
|
||||
onOpenSearch: () => void
|
||||
onOpenBases: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}
|
||||
|
||||
type FlatRow = {
|
||||
node: TreeNode
|
||||
depth: number
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
function flatten(
|
||||
nodes: TreeNode[],
|
||||
expanded: Set<string>,
|
||||
depth: number,
|
||||
out: FlatRow[],
|
||||
): void {
|
||||
for (const node of sortNodes(nodes)) {
|
||||
out.push({ node, depth })
|
||||
if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) {
|
||||
flatten(node.children, expanded, depth + 1, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatModified(mtimeMs?: number): string {
|
||||
if (!mtimeMs) return ''
|
||||
return formatRelativeTime(new Date(mtimeMs).toISOString())
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('mac')) return 'Finder'
|
||||
if (platform.includes('win')) return 'Explorer'
|
||||
return 'File Manager'
|
||||
}
|
||||
|
||||
function displayName(node: TreeNode): string {
|
||||
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
||||
return node.name.slice(0, -3)
|
||||
}
|
||||
return node.name
|
||||
}
|
||||
|
||||
const INDENT_PX = 16
|
||||
const ROW_PADDING_PX = 12
|
||||
|
||||
export function KnowledgeView({
|
||||
tree,
|
||||
actions,
|
||||
onOpenNote,
|
||||
onOpenGraph,
|
||||
onOpenSearch,
|
||||
onOpenBases,
|
||||
onVoiceNoteCreated,
|
||||
}: KnowledgeViewProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
|
||||
const rows = useMemo<FlatRow[]>(() => {
|
||||
const out: FlatRow[] = []
|
||||
flatten(tree, expanded, 0, out)
|
||||
return out
|
||||
}, [tree, expanded])
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(node: TreeNode) => {
|
||||
if (node.kind === 'dir') {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(node.path)) next.delete(node.path)
|
||||
else next.add(node.path)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
onOpenNote(node.path)
|
||||
}
|
||||
},
|
||||
[onOpenNote],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Knowledge</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actions.createNote()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<FilePlus className="size-4" />
|
||||
<span>New note</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void actions.createFolder()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<FolderPlus className="size-4" />
|
||||
<span>New folder</span>
|
||||
</button>
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBases}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Table2 className="size-4" />
|
||||
<span>Bases</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenGraph}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Network className="size-4" />
|
||||
<span>Graph view</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actions.revealInFileManager('knowledge', true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
<span>Open in {getFileManagerName()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="min-w-[480px]">
|
||||
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex-1">Page name</div>
|
||||
<div className="w-32 shrink-0">Modified</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
|
||||
) : (
|
||||
rows.map(({ node, depth }) => (
|
||||
<KnowledgeRow
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={depth}
|
||||
isExpanded={expanded.has(node.path)}
|
||||
actions={actions}
|
||||
renameActive={renameTarget === node.path}
|
||||
onRequestRename={(p) => setRenameTarget(p)}
|
||||
onClearRename={() => setRenameTarget(null)}
|
||||
onClick={handleRowClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeRow({
|
||||
node,
|
||||
depth,
|
||||
isExpanded,
|
||||
actions,
|
||||
renameActive,
|
||||
onRequestRename,
|
||||
onClearRename,
|
||||
onClick,
|
||||
}: {
|
||||
node: TreeNode
|
||||
depth: number
|
||||
isExpanded: boolean
|
||||
actions: KnowledgeViewActions
|
||||
renameActive: boolean
|
||||
onRequestRename: (path: string) => void
|
||||
onClearRename: () => void
|
||||
onClick: (node: TreeNode) => void
|
||||
}) {
|
||||
const isDir = node.kind === 'dir'
|
||||
const Icon = isDir ? FolderIcon : FileIcon
|
||||
const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX
|
||||
const baseName = displayName(node)
|
||||
|
||||
const [newName, setNewName] = useState(baseName)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (renameActive) {
|
||||
setNewName(baseName)
|
||||
isSubmittingRef.current = false
|
||||
// focus on next tick after mount
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}
|
||||
}, [renameActive, baseName])
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
const trimmed = newName.trim()
|
||||
if (trimmed && trimmed !== baseName) {
|
||||
try {
|
||||
await actions.rename(node.path, trimmed, isDir)
|
||||
toast('Renamed successfully', 'success')
|
||||
} catch {
|
||||
toast('Failed to rename', 'error')
|
||||
}
|
||||
}
|
||||
onClearRename()
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}, [actions, baseName, isDir, newName, node.path, onClearRename])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
isSubmittingRef.current = true
|
||||
setNewName(baseName)
|
||||
onClearRename()
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}, [baseName, onClearRename])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
try {
|
||||
await actions.remove(node.path)
|
||||
toast('Moved to trash', 'success')
|
||||
} catch {
|
||||
toast('Failed to delete', 'error')
|
||||
}
|
||||
}, [actions, node.path])
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
actions.copyPath(node.path)
|
||||
toast('Path copied', 'success')
|
||||
}, [actions, node.path])
|
||||
|
||||
const row = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(node)}
|
||||
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
|
||||
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isDir ? (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 transition-transform',
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
{renameActive ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleRenameSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isSubmittingRef.current) void handleRenameSubmit()
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{baseName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{formatModified(node.stat?.mtimeMs)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{isDir && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.createNote(node.path)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{!isDir && actions.onOpenInNewTab && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(node.path)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={handleCopyPath}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.revealInFileManager(node.path, isDir)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onRequestRename(node.path)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,11 +4,10 @@ import * as React from "react"
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Bot,
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
|
|
@ -17,10 +16,7 @@ import {
|
|||
AlertTriangle,
|
||||
HelpCircle,
|
||||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
LayoutGrid,
|
||||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
|
|
@ -30,12 +26,6 @@ import {
|
|||
Square,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -64,10 +54,8 @@ import {
|
|||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
|
@ -105,6 +93,7 @@ interface TreeNode {
|
|||
kind: "file" | "dir"
|
||||
children?: TreeNode[]
|
||||
loaded?: boolean
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
type KnowledgeActions = {
|
||||
|
|
@ -112,6 +101,7 @@ type KnowledgeActions = {
|
|||
createFolder: (parentPath?: string) => Promise<string>
|
||||
openGraph: () => void
|
||||
openBases: () => void
|
||||
openKnowledgeView: () => void
|
||||
openWorkspaceAt: (path?: string) => void
|
||||
createWorkspace: (name: string) => Promise<string>
|
||||
expandAll: () => void
|
||||
|
|
@ -123,6 +113,13 @@ type KnowledgeActions = {
|
|||
onOpenInNewTab?: (path: string) => void
|
||||
}
|
||||
|
||||
function displayNoteName(node: TreeNode): string {
|
||||
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
||||
return node.name.slice(0, -3)
|
||||
}
|
||||
return node.name
|
||||
}
|
||||
|
||||
function getFileManagerName(): string {
|
||||
if (typeof navigator === 'undefined') return 'File Manager'
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
|
|
@ -202,11 +199,8 @@ type TasksActions = {
|
|||
type SidebarContentPanelProps = {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
knowledgeActions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
runs?: RunListItem[]
|
||||
currentRunId?: string | null
|
||||
processingRunIds?: Set<string>
|
||||
|
|
@ -454,11 +448,8 @@ function SyncStatusBar() {
|
|||
export function SidebarContentPanel({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
knowledgeActions,
|
||||
onVoiceNoteCreated,
|
||||
runs = [],
|
||||
currentRunId,
|
||||
processingRunIds,
|
||||
|
|
@ -607,12 +598,10 @@ export function SidebarContentPanel({
|
|||
<KnowledgeSection
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={knowledgeActions}
|
||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||
/>
|
||||
<WorkspaceSection tree={tree} actions={knowledgeActions} />
|
||||
<TasksSection
|
||||
runs={runs}
|
||||
currentRunId={currentRunId}
|
||||
|
|
@ -814,7 +803,7 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise<string | null> {
|
|||
}
|
||||
|
||||
// Voice Note Recording Button
|
||||
function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) {
|
||||
export function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) {
|
||||
const [isRecording, setIsRecording] = React.useState(false)
|
||||
const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false)
|
||||
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
|
||||
|
|
@ -1030,29 +1019,84 @@ path: ${currentRelativePath}
|
|||
function KnowledgeSection({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
onVoiceNoteCreated,
|
||||
}: {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
actions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}) {
|
||||
const isExpanded = expandedPaths.size > 0
|
||||
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
const [knowledgeTab, setKnowledgeTab] = useState<'notes' | 'workspaces'>('notes')
|
||||
const visibleTree = React.useMemo(
|
||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings' && item.path !== 'knowledge/Workspace'),
|
||||
[tree],
|
||||
)
|
||||
const recentNotes = React.useMemo<TreeNode[]>(() => {
|
||||
const out: TreeNode[] = []
|
||||
const walk = (nodes: TreeNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'file') out.push(n)
|
||||
else if (n.children?.length) walk(n.children)
|
||||
}
|
||||
}
|
||||
walk(visibleTree)
|
||||
return out
|
||||
.filter((n) => n.stat?.mtimeMs !== undefined)
|
||||
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
||||
.slice(0, 3)
|
||||
}, [visibleTree])
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarGroup className="flex flex-col">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Knowledge
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{recentNotes.map((note) => (
|
||||
<SidebarMenuItem key={note.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedPath === note.path}
|
||||
onClick={() => onSelectFile(note.path, 'file')}
|
||||
>
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<span className="truncate">{displayNoteName(note)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => actions.openKnowledgeView()}>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">View all</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => actions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void actions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceSection({
|
||||
tree,
|
||||
actions,
|
||||
}: {
|
||||
tree: TreeNode[]
|
||||
actions: KnowledgeActions
|
||||
}) {
|
||||
const workspaceFolders = React.useMemo<TreeNode[]>(() => {
|
||||
const root = tree.find((item) => item.path === 'knowledge/Workspace')
|
||||
const children = root?.children ?? []
|
||||
|
|
@ -1061,187 +1105,18 @@ function KnowledgeSection({
|
|||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [tree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath) return
|
||||
|
||||
let cancelled = false
|
||||
let rafId: number | null = null
|
||||
let attempts = 0
|
||||
const maxAttempts = 20
|
||||
|
||||
const revealActiveFile = () => {
|
||||
if (cancelled) return
|
||||
const container = treeContainerRef.current
|
||||
if (!container) return
|
||||
const activeRow = container.querySelector<HTMLElement>('[data-knowledge-active="true"]')
|
||||
if (activeRow) {
|
||||
activeRow.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
return
|
||||
}
|
||||
if (attempts >= maxAttempts) return
|
||||
attempts += 1
|
||||
rafId = requestAnimationFrame(revealActiveFile)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(revealActiveFile)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [selectedPath, expandedPaths, visibleTree])
|
||||
|
||||
// Folder clicks highlight the folder; file clicks clear folder highlight
|
||||
const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => {
|
||||
if (kind === 'dir') {
|
||||
setSelectedFolderPath(path)
|
||||
} else {
|
||||
setSelectedFolderPath(null)
|
||||
}
|
||||
onSelectFile(path, kind)
|
||||
}, [onSelectFile])
|
||||
|
||||
// Resolve the parent path for new items: explicit folder > open file's parent > root
|
||||
const deriveParent = React.useCallback((): string => {
|
||||
if (selectedFolderPath) return selectedFolderPath
|
||||
if (selectedPath) {
|
||||
const parts = selectedPath.split('/')
|
||||
if (parts.length > 1) return parts.slice(0, -1).join('/')
|
||||
}
|
||||
return 'knowledge'
|
||||
}, [selectedFolderPath, selectedPath])
|
||||
|
||||
// Wrap actions to inject context-aware parent and capture rename target
|
||||
const wrappedActions = React.useMemo<KnowledgeActions>(() => ({
|
||||
...actions,
|
||||
createNote: (parentPath?: string) => actions.createNote(parentPath ?? deriveParent()),
|
||||
createFolder: async (parentPath?: string): Promise<string> => {
|
||||
const newPath = await actions.createFolder(parentPath ?? deriveParent())
|
||||
setRenameTarget(newPath)
|
||||
return newPath
|
||||
},
|
||||
}), [actions, deriveParent])
|
||||
|
||||
const fileManagerName = getFileManagerName()
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => wrappedActions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => void wrappedActions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||
{ icon: FolderOpen, label: `Open in ${fileManagerName}`, action: () => actions.revealInFileManager('knowledge', true) },
|
||||
]
|
||||
|
||||
return (
|
||||
<SidebarGroup className="flex flex-col">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Knowledge
|
||||
Workspace
|
||||
</div>
|
||||
<div className="px-2 pb-1">
|
||||
<div className="flex w-full rounded-md bg-sidebar-accent/40 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKnowledgeTab('notes')}
|
||||
className={cn(
|
||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||
knowledgeTab === 'notes'
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKnowledgeTab('workspaces')}
|
||||
className={cn(
|
||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||
knowledgeTab === 'workspaces'
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
Workspaces
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeTab === 'notes' ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-center gap-1 py-1 sticky top-0 z-10 bg-sidebar border-b border-sidebar-border">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={isExpanded ? actions.collapseAll : actions.expandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<div ref={treeContainerRef}>
|
||||
<SidebarMenu>
|
||||
{visibleTree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={handleSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={wrappedActions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={() => setRenameTarget(null)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => wrappedActions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void wrappedActions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<WorkspacesPanel
|
||||
workspaces={workspaceFolders}
|
||||
onOpen={(path) => actions.openWorkspaceAt(path)}
|
||||
onOpenRoot={() => actions.openWorkspaceAt()}
|
||||
onCreate={actions.createWorkspace}
|
||||
onRevealInFileManager={() => actions.revealInFileManager('knowledge/Workspace', true)}
|
||||
/>
|
||||
)}
|
||||
<WorkspacesPanel
|
||||
workspaces={workspaceFolders}
|
||||
onOpen={(path) => actions.openWorkspaceAt(path)}
|
||||
onOpenRoot={() => actions.openWorkspaceAt()}
|
||||
onCreate={actions.createWorkspace}
|
||||
onRevealInFileManager={() => actions.revealInFileManager('knowledge/Workspace', true)}
|
||||
/>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
|
@ -1395,317 +1270,6 @@ function WorkspacesPanel({
|
|||
)
|
||||
}
|
||||
|
||||
function countFiles(node: TreeNode): number {
|
||||
if (node.kind === 'file') return 1
|
||||
return (node.children ?? []).reduce((sum, child) => sum + countFiles(child), 0)
|
||||
}
|
||||
|
||||
/** Display name overrides for top-level knowledge folders */
|
||||
const FOLDER_DISPLAY_NAMES: Record<string, string> = {}
|
||||
|
||||
// Tree component for file browser
|
||||
function Tree({
|
||||
item,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
selectedFolderPath,
|
||||
renameTarget,
|
||||
onRenameTargetConsumed,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelect: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
actions: KnowledgeActions
|
||||
selectedFolderPath?: string | null
|
||||
renameTarget?: string | null
|
||||
onRenameTargetConsumed?: () => void
|
||||
}) {
|
||||
const isDir = item.kind === 'dir'
|
||||
const isExpanded = expandedPaths.has(item.path)
|
||||
const isSelected = selectedPath === item.path
|
||||
const isFolderSelected = isDir && selectedFolderPath === item.path
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const isSubmittingRef = React.useRef(false)
|
||||
const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name
|
||||
|
||||
// For files, strip .md extension for editing
|
||||
const baseName = !isDir && item.name.endsWith('.md')
|
||||
? item.name.slice(0, -3)
|
||||
: item.name
|
||||
const [newName, setNewName] = useState(baseName)
|
||||
|
||||
// Auto-enter rename mode when this node is the rename target
|
||||
React.useEffect(() => {
|
||||
if (renameTarget === item.path) {
|
||||
setNewName(baseName)
|
||||
isSubmittingRef.current = false
|
||||
setIsRenaming(true)
|
||||
onRenameTargetConsumed?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [renameTarget, item.path])
|
||||
|
||||
// Sync newName when baseName changes (e.g., after external rename)
|
||||
React.useEffect(() => {
|
||||
setNewName(baseName)
|
||||
}, [baseName])
|
||||
|
||||
const handleRename = async () => {
|
||||
// Prevent double submission
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
|
||||
const trimmedName = newName.trim()
|
||||
if (trimmedName && trimmedName !== baseName) {
|
||||
try {
|
||||
await actions.rename(item.path, trimmedName, isDir)
|
||||
toast('Renamed successfully', 'success')
|
||||
} catch {
|
||||
toast('Failed to rename', 'error')
|
||||
}
|
||||
}
|
||||
setIsRenaming(false)
|
||||
// Reset after a small delay to prevent blur from re-triggering
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await actions.remove(item.path)
|
||||
toast('Moved to trash', 'success')
|
||||
} catch {
|
||||
toast('Failed to delete', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPath = () => {
|
||||
actions.copyPath(item.path)
|
||||
toast('Path copied', 'success')
|
||||
}
|
||||
|
||||
const cancelRename = () => {
|
||||
isSubmittingRef.current = true // Prevent blur from triggering rename
|
||||
setIsRenaming(false)
|
||||
setNewName(baseName) // Reset to original name
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const contextMenuContent = (
|
||||
<ContextMenuContent className="w-48">
|
||||
{isDir && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.createFolder(item.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{!isDir && actions.onOpenInNewTab && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={handleCopyPath}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
|
||||
<FolderOpen className="mr-2 size-4" />
|
||||
Open in {getFileManagerName()}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
|
||||
// Inline rename input
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center px-2 py-1">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
await handleRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Only trigger rename if not already submitting
|
||||
if (!isSubmittingRef.current) {
|
||||
handleRename()
|
||||
}
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Top-level knowledge folders open bases view — render as flat items
|
||||
const parts = item.path.split('/')
|
||||
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge'
|
||||
|
||||
if (isBasesFolder) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton isActive={isFolderSelected} onClick={() => onSelect(item.path, item.kind)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
{onToggleFolder && (item.children?.length ?? 0) > 0 && (
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
aria-label={isExpanded ? "Collapse folder" : "Expand folder"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFolder(item.path)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<SidebarMenuSub>
|
||||
{(item.children ?? []).map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={onRenameTargetConsumed}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem
|
||||
className="group/file-item"
|
||||
data-knowledge-file-path={item.path}
|
||||
data-knowledge-active={isSelected ? "true" : "false"}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isSelected}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey && actions.onOpenInNewTab) {
|
||||
actions.onOpenInNewTab(item.path)
|
||||
} else {
|
||||
onSelect(item.path, item.kind)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton isActive={isFolderSelected}>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{(item.children ?? []).map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={onRenameTargetConsumed}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Get status indicator color
|
||||
function getStatusColor(status?: string, enabled?: boolean): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue