mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
new chat icon/shortcut, workspaces, and knowledge browsing
Move new chat to a top-bar icon with a Cmd+N shortcut, introduce the Workspaces concept (workspace sidebar + default the working-directory picker to it), and expand the knowledge browser (view more). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5be0a11f98
commit
346c685ac9
6 changed files with 1139 additions and 704 deletions
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
|
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
|
|
@ -25,6 +25,8 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||||
import { LiveNotesView } from '@/components/live-notes-view';
|
import { LiveNotesView } from '@/components/live-notes-view';
|
||||||
import { BgTasksView } from '@/components/bg-tasks-view';
|
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||||
import { EmailView } from '@/components/email-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 { MeetingsView } from '@/components/meetings-view';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
import {
|
import {
|
||||||
|
|
@ -184,6 +186,9 @@ const MEETINGS_TAB_PATH = '__rowboat_meetings__'
|
||||||
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
|
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
|
||||||
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
|
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
|
||||||
const EMAIL_TAB_PATH = '__rowboat_email__'
|
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 BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
|
|
@ -317,6 +322,8 @@ const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
|
||||||
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
|
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
|
||||||
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
|
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
|
||||||
const isEmailTabPath = (path: string) => path === EMAIL_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 isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||||
|
|
@ -567,12 +574,15 @@ type ViewState =
|
||||||
| { type: 'meetings' }
|
| { type: 'meetings' }
|
||||||
| { type: 'live-notes' }
|
| { type: 'live-notes' }
|
||||||
| { type: 'email' }
|
| { type: 'email' }
|
||||||
|
| { type: 'workspace'; path?: string }
|
||||||
|
| { type: 'knowledge-view' }
|
||||||
|
|
||||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type !== b.type) return false
|
if (a.type !== b.type) return false
|
||||||
if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId
|
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 === 'file' && b.type === 'file') return a.path === b.path
|
||||||
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
||||||
|
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
|
||||||
return true // both graph
|
return true // both graph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -616,6 +626,12 @@ function parseDeepLink(input: string): ViewState | null {
|
||||||
return { type: 'meetings' }
|
return { type: 'meetings' }
|
||||||
case 'live-notes':
|
case 'live-notes':
|
||||||
return { type: 'live-notes' }
|
return { type: 'live-notes' }
|
||||||
|
case 'workspace': {
|
||||||
|
const path = params.get('path')
|
||||||
|
return { type: 'workspace', path: path ?? undefined }
|
||||||
|
}
|
||||||
|
case 'knowledge-view':
|
||||||
|
return { type: 'knowledge-view' }
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -624,12 +640,16 @@ function parseDeepLink(input: string): ViewState | null {
|
||||||
/** Sidebar toggle (fixed position, top-left) */
|
/** Sidebar toggle (fixed position, top-left) */
|
||||||
function FixedSidebarToggle({
|
function FixedSidebarToggle({
|
||||||
leftInsetPx,
|
leftInsetPx,
|
||||||
|
onNewChat,
|
||||||
|
onOpenSearch,
|
||||||
}: {
|
}: {
|
||||||
leftInsetPx: number
|
leftInsetPx: number
|
||||||
|
onNewChat?: () => void
|
||||||
|
onOpenSearch?: () => void
|
||||||
}) {
|
}) {
|
||||||
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 gap-1" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||||
{/* Sidebar toggle */}
|
{/* Sidebar toggle */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -641,6 +661,28 @@ function FixedSidebarToggle({
|
||||||
>
|
>
|
||||||
<PanelLeftIcon className="size-5" />
|
<PanelLeftIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
{onNewChat && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
aria-label="New chat"
|
||||||
|
title="New chat"
|
||||||
|
>
|
||||||
|
<SquarePen className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onOpenSearch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSearch}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
aria-label="Search"
|
||||||
|
title="Search"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -725,6 +767,9 @@ function App() {
|
||||||
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
|
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
|
||||||
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
|
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
|
||||||
const [isEmailOpen, setIsEmailOpen] = useState(false)
|
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<{
|
const [expandedFrom, setExpandedFrom] = useState<{
|
||||||
path: string | null
|
path: string | null
|
||||||
graph: boolean
|
graph: boolean
|
||||||
|
|
@ -1079,6 +1124,8 @@ function App() {
|
||||||
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
|
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
|
||||||
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
|
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
|
||||||
if (isEmailTabPath(tab.path)) return 'Email'
|
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 === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
|
|
@ -2793,7 +2840,7 @@ function App() {
|
||||||
setActiveFileTabId(existingTab.id)
|
setActiveFileTabId(existingTab.id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2802,7 +2849,7 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
}, [fileTabs, dismissBrowserOverlay])
|
}, [fileTabs, dismissBrowserOverlay])
|
||||||
|
|
||||||
|
|
@ -2821,14 +2868,14 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isLiveNotesTabPath(tab.path)) {
|
if (isLiveNotesTabPath(tab.path)) {
|
||||||
|
|
@ -2838,6 +2885,8 @@ function App() {
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2847,6 +2896,9 @@ function App() {
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsBgTasksOpen(true)
|
setIsBgTasksOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2857,26 +2909,56 @@ function App() {
|
||||||
setIsMeetingsOpen(true)
|
setIsMeetingsOpen(true)
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isEmailTabPath(tab.path)) {
|
if (isEmailTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false)
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isWorkspaceTabPath(tab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false)
|
||||||
|
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)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedPath(tab.path)
|
setSelectedPath(tab.path)
|
||||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||||
|
|
||||||
const closeFileTab = useCallback((tabId: string) => {
|
const closeFileTab = useCallback((tabId: string) => {
|
||||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
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) && !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)
|
removeEditorCacheForPath(closingTab.path)
|
||||||
initialContentByPathRef.current.delete(closingTab.path)
|
initialContentByPathRef.current.delete(closingTab.path)
|
||||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||||
|
|
@ -2899,7 +2981,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const idx = prev.findIndex(t => t.id === tabId)
|
const idx = prev.findIndex(t => t.id === tabId)
|
||||||
|
|
@ -2913,12 +2995,12 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -2927,6 +3009,8 @@ function App() {
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -2934,6 +3018,8 @@ function App() {
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
|
@ -2943,6 +3029,8 @@ function App() {
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(true)
|
setIsBgTasksOpen(true)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
} else if (isEmailTabPath(newActiveTab.path)) {
|
} else if (isEmailTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -2950,11 +3038,33 @@ function App() {
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
|
} else if (isWorkspaceTabPath(newActiveTab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false)
|
||||||
|
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 {
|
} else {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedPath(newActiveTab.path)
|
setSelectedPath(newActiveTab.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2985,7 +3095,7 @@ function App() {
|
||||||
dismissBrowserOverlay()
|
dismissBrowserOverlay()
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
// Left-pane "new chat" should always open full chat view.
|
// Left-pane "new chat" should always open full chat view.
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) {
|
||||||
setExpandedFrom({
|
setExpandedFrom({
|
||||||
path: selectedPath,
|
path: selectedPath,
|
||||||
graph: isGraphOpen,
|
graph: isGraphOpen,
|
||||||
|
|
@ -3002,8 +3112,8 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
|
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen])
|
||||||
|
|
||||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||||
const handleNewChatTabInSidebar = useCallback(() => {
|
const handleNewChatTabInSidebar = useCallback(() => {
|
||||||
|
|
@ -3135,7 +3245,7 @@ function App() {
|
||||||
|
|
||||||
const handleOpenFullScreenChat = useCallback(() => {
|
const handleOpenFullScreenChat = useCallback(() => {
|
||||||
// Remember where we came from so the close button can return
|
// Remember where we came from so the close button can return
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) {
|
||||||
setExpandedFrom({
|
setExpandedFrom({
|
||||||
path: selectedPath,
|
path: selectedPath,
|
||||||
graph: isGraphOpen,
|
graph: isGraphOpen,
|
||||||
|
|
@ -3151,19 +3261,19 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback(() => {
|
||||||
if (expandedFrom) {
|
if (expandedFrom) {
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
} else if (expandedFrom.suggestedTopics) {
|
} else if (expandedFrom.suggestedTopics) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
} else if (expandedFrom.meetings) {
|
} else if (expandedFrom.meetings) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
|
@ -3195,7 +3305,7 @@ function App() {
|
||||||
} else if (expandedFrom.path) {
|
} else if (expandedFrom.path) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -3209,10 +3319,12 @@ function App() {
|
||||||
if (isMeetingsOpen) return { type: 'meetings' }
|
if (isMeetingsOpen) return { type: 'meetings' }
|
||||||
if (isLiveNotesOpen) return { type: 'live-notes' }
|
if (isLiveNotesOpen) return { type: 'live-notes' }
|
||||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
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 (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, workspaceInitialPath, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -3313,6 +3425,28 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
}, [fileTabs])
|
}, [fileTabs])
|
||||||
|
|
||||||
|
const ensureWorkspaceFileTab = useCallback(() => {
|
||||||
|
const existing = fileTabs.find((tab) => isWorkspaceTabPath(tab.path))
|
||||||
|
if (existing) {
|
||||||
|
setActiveFileTabId(existing.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = newFileTabId()
|
||||||
|
setFileTabs((prev) => [...prev, { id, path: WORKSPACE_TAB_PATH }])
|
||||||
|
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(() => {
|
const openEmailView = useCallback(() => {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3321,6 +3455,8 @@ function App() {
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
|
|
@ -3333,7 +3469,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
|
|
@ -3350,6 +3486,8 @@ function App() {
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
|
|
@ -3365,7 +3503,7 @@ function App() {
|
||||||
// visible in the middle pane.
|
// visible in the middle pane.
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||||
// Only exit chat-only maximize, because that would hide the selected file.
|
// Only exit chat-only maximize, because that would hide the selected file.
|
||||||
|
|
@ -3380,7 +3518,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
ensureGraphFileTab()
|
ensureGraphFileTab()
|
||||||
|
|
@ -3393,7 +3531,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(view.name)
|
setSelectedBackgroundTask(view.name)
|
||||||
|
|
@ -3406,7 +3544,7 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
ensureSuggestedTopicsFileTab()
|
ensureSuggestedTopicsFileTab()
|
||||||
return
|
return
|
||||||
case 'meetings':
|
case 'meetings':
|
||||||
|
|
@ -3421,6 +3559,8 @@ function App() {
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
ensureMeetingsFileTab()
|
ensureMeetingsFileTab()
|
||||||
return
|
return
|
||||||
case 'live-notes':
|
case 'live-notes':
|
||||||
|
|
@ -3434,6 +3574,8 @@ function App() {
|
||||||
setIsMeetingsOpen(false)
|
setIsMeetingsOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
ensureLiveNotesFileTab()
|
ensureLiveNotesFileTab()
|
||||||
return
|
return
|
||||||
|
|
@ -3449,8 +3591,43 @@ function App() {
|
||||||
setIsLiveNotesOpen(false)
|
setIsLiveNotesOpen(false)
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
ensureEmailFileTab()
|
ensureEmailFileTab()
|
||||||
return
|
return
|
||||||
|
case 'workspace':
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setExpandedFrom(null)
|
||||||
|
setIsRightPaneMaximized(false)
|
||||||
|
setSelectedBackgroundTask(null)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false)
|
||||||
|
setIsLiveNotesOpen(false)
|
||||||
|
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':
|
case 'chat':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3459,7 +3636,7 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false)
|
||||||
if (view.runId) {
|
if (view.runId) {
|
||||||
await loadRun(view.runId)
|
await loadRun(view.runId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3467,7 +3644,7 @@ function App() {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||||
|
|
||||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||||
const current = currentViewState
|
const current = currentViewState
|
||||||
|
|
@ -3789,7 +3966,7 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen
|
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
|
@ -3817,6 +3994,18 @@ function App() {
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keyboard shortcut: Cmd+N / Ctrl+N opens a new chat tab.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'n') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleNewChatTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleNewChatTab])
|
||||||
|
|
||||||
// Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior).
|
// Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleHistoryKeyDown = (e: KeyboardEvent) => {
|
const handleHistoryKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -3862,11 +4051,11 @@ function App() {
|
||||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
const mod = e.metaKey || e.ctrlKey
|
const mod = e.metaKey || e.ctrlKey
|
||||||
if (!mod) return
|
if (!mod) return
|
||||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen)
|
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && isChatSidebarOpen)
|
||||||
const targetPane: ShortcutPane = rightPaneAvailable
|
const targetPane: ShortcutPane = rightPaneAvailable
|
||||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||||
: 'left'
|
: 'left'
|
||||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen)
|
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen)
|
||||||
const selectedKnowledgePath = isGraphOpen
|
const selectedKnowledgePath = isGraphOpen
|
||||||
? GRAPH_TAB_PATH
|
? GRAPH_TAB_PATH
|
||||||
: isSuggestedTopicsOpen
|
: isSuggestedTopicsOpen
|
||||||
|
|
@ -3879,6 +4068,10 @@ function App() {
|
||||||
? BG_TASKS_TAB_PATH
|
? BG_TASKS_TAB_PATH
|
||||||
: isEmailOpen
|
: isEmailOpen
|
||||||
? EMAIL_TAB_PATH
|
? EMAIL_TAB_PATH
|
||||||
|
: isWorkspaceOpen
|
||||||
|
? WORKSPACE_TAB_PATH
|
||||||
|
: isKnowledgeViewOpen
|
||||||
|
? KNOWLEDGE_VIEW_TAB_PATH
|
||||||
: selectedPath
|
: selectedPath
|
||||||
const targetFileTabId = activeFileTabId ?? (
|
const targetFileTabId = activeFileTabId ?? (
|
||||||
selectedKnowledgePath
|
selectedKnowledgePath
|
||||||
|
|
@ -3933,7 +4126,7 @@ function App() {
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleTabKeyDown)
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, 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') => {
|
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
|
|
@ -3958,7 +4151,7 @@ function App() {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
|
@ -4084,19 +4277,49 @@ function App() {
|
||||||
},
|
},
|
||||||
openGraph: () => {
|
openGraph: () => {
|
||||||
// From chat-only landing state, open graph directly in full knowledge view.
|
// From chat-only landing state, open graph directly in full knowledge view.
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'graph' })
|
void navigateToView({ type: 'graph' })
|
||||||
},
|
},
|
||||||
openBases: () => {
|
openBases: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) {
|
||||||
setIsChatSidebarOpen(false)
|
setIsChatSidebarOpen(false)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
},
|
},
|
||||||
|
openWorkspaceAt: (path?: string) => {
|
||||||
|
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')
|
||||||
|
if (trimmed.includes('/')) throw new Error('Name cannot contain "/"')
|
||||||
|
const rootExists = await window.ipc.invoke('workspace:exists', { path: WORKSPACE_ROOT })
|
||||||
|
if (!rootExists.exists) {
|
||||||
|
await window.ipc.invoke('workspace:mkdir', { path: WORKSPACE_ROOT, recursive: true })
|
||||||
|
}
|
||||||
|
const target = `${WORKSPACE_ROOT}/${trimmed}`
|
||||||
|
const exists = await window.ipc.invoke('workspace:exists', { path: target })
|
||||||
|
if (exists.exists) {
|
||||||
|
throw new Error(`A workspace named "${trimmed}" already exists`)
|
||||||
|
}
|
||||||
|
await window.ipc.invoke('workspace:mkdir', { path: target, recursive: true })
|
||||||
|
return target
|
||||||
|
},
|
||||||
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||||
collapseAll: () => setExpandedPaths(new Set()),
|
collapseAll: () => setExpandedPaths(new Set()),
|
||||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||||
|
|
@ -4690,7 +4913,7 @@ function App() {
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
const openMarkdownTabs = React.useMemo(() => {
|
const openMarkdownTabs = React.useMemo(() => {
|
||||||
|
|
@ -4707,7 +4930,7 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) {
|
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen) {
|
||||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -4721,18 +4944,8 @@ function App() {
|
||||||
<SidebarContentPanel
|
<SidebarContentPanel
|
||||||
tree={tree}
|
tree={tree}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
expandedPaths={expandedPaths}
|
|
||||||
onSelectFile={toggleExpand}
|
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}
|
knowledgeActions={knowledgeActions}
|
||||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
|
||||||
runs={runs}
|
runs={runs}
|
||||||
currentRunId={runId}
|
currentRunId={runId}
|
||||||
processingRunIds={processingRunIds}
|
processingRunIds={processingRunIds}
|
||||||
|
|
@ -4740,7 +4953,7 @@ function App() {
|
||||||
onNewChat: handleNewChatTab,
|
onNewChat: handleNewChatTab,
|
||||||
onSelectRun: (runIdToLoad) => {
|
onSelectRun: (runIdToLoad) => {
|
||||||
cancelRecordingIfActive()
|
cancelRecordingIfActive()
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4751,7 +4964,7 @@ function App() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
// 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 || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||||
loadRun(runIdToLoad)
|
loadRun(runIdToLoad)
|
||||||
return
|
return
|
||||||
|
|
@ -4775,14 +4988,14 @@ function App() {
|
||||||
} else {
|
} else {
|
||||||
// Only one tab, reset it to new chat
|
// Only one tab, reset it to new chat
|
||||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
void navigateToView({ type: 'chat', runId: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (runId === runIdToDelete) {
|
} else if (runId === runIdToDelete) {
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) {
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4800,8 +5013,6 @@ function App() {
|
||||||
}}
|
}}
|
||||||
backgroundTasks={backgroundTasks}
|
backgroundTasks={backgroundTasks}
|
||||||
selectedBackgroundTask={selectedBackgroundTask}
|
selectedBackgroundTask={selectedBackgroundTask}
|
||||||
onNewChat={handleNewChatTab}
|
|
||||||
onOpenSearch={() => setIsSearchOpen(true)}
|
|
||||||
isSearchOpen={isSearchOpen}
|
isSearchOpen={isSearchOpen}
|
||||||
isBrowserOpen={isBrowserOpen}
|
isBrowserOpen={isBrowserOpen}
|
||||||
onToggleBrowser={handleToggleBrowser}
|
onToggleBrowser={handleToggleBrowser}
|
||||||
|
|
@ -4809,8 +5020,6 @@ function App() {
|
||||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||||
isMeetingsOpen={isMeetingsOpen}
|
isMeetingsOpen={isMeetingsOpen}
|
||||||
onOpenMeetings={openMeetingsView}
|
onOpenMeetings={openMeetingsView}
|
||||||
isLiveNotesOpen={isLiveNotesOpen}
|
|
||||||
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
|
|
||||||
isBgTasksOpen={isBgTasksOpen}
|
isBgTasksOpen={isBgTasksOpen}
|
||||||
onOpenBgTasks={openBgTasksView}
|
onOpenBgTasks={openBgTasksView}
|
||||||
isEmailOpen={isEmailOpen}
|
isEmailOpen={isEmailOpen}
|
||||||
|
|
@ -4834,7 +5043,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -4842,7 +5051,7 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -4895,7 +5104,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedTask && !isBrowserOpen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4910,7 +5119,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isBrowserOpen && expandedFrom && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4925,7 +5134,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -4991,6 +5200,35 @@ 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">
|
||||||
<EmailView />
|
<EmailView />
|
||||||
</div>
|
</div>
|
||||||
|
) : isWorkspaceOpen ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<WorkspaceView
|
||||||
|
tree={tree}
|
||||||
|
initialPath={workspaceInitialPath}
|
||||||
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
|
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) ? (
|
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<BasesView
|
<BasesView
|
||||||
|
|
@ -5413,6 +5651,8 @@ function App() {
|
||||||
{/* 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
|
||||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||||
|
onNewChat={handleNewChatTab}
|
||||||
|
onOpenSearch={() => setIsSearchOpen(true)}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -274,9 +274,21 @@ function ChatInputInner({
|
||||||
|
|
||||||
const handleSetWorkDir = useCallback(async () => {
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
let defaultPath: string | undefined = workDir ?? undefined
|
||||||
|
try {
|
||||||
|
const { root } = await window.ipc.invoke('workspace:getRoot', null)
|
||||||
|
const workspaceRel = 'knowledge/Workspace'
|
||||||
|
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
|
||||||
|
if (!exists.exists) {
|
||||||
|
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
|
||||||
|
}
|
||||||
|
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
|
||||||
|
}
|
||||||
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
||||||
title: 'Choose work directory',
|
title: 'Choose work directory',
|
||||||
defaultPath: workDir ?? undefined,
|
defaultPath,
|
||||||
})
|
})
|
||||||
if (!chosen) return
|
if (!chosen) return
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,11 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
const MAX_SIZE_BYTES = 5 * 1024 * 1024
|
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 =
|
type ViewerState =
|
||||||
| { kind: 'loading' }
|
| { kind: 'loading' }
|
||||||
| { kind: 'loaded'; html: string }
|
| { kind: 'loaded' }
|
||||||
| { kind: 'empty' }
|
| { kind: 'empty' }
|
||||||
| { kind: 'tooLarge'; sizeMB: number }
|
| { kind: 'tooLarge'; sizeMB: number }
|
||||||
| { kind: 'error'; message: string }
|
| { kind: 'error'; message: string }
|
||||||
|
|
@ -36,9 +14,15 @@ interface HtmlFileViewerProps {
|
||||||
path: string
|
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) {
|
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||||
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
|
||||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||||
|
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
||||||
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
|
if (stat.size === 0) {
|
||||||
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() === '') {
|
|
||||||
setState({ kind: 'empty' })
|
setState({ kind: 'empty' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setState({ kind: 'loaded', html: result.data })
|
setState({ kind: 'loaded' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
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
|
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
|
||||||
// gets a null origin with no base URL. Trade-off: relative assets inside
|
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
|
||||||
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
|
// to the file resolve correctly (the path-traversal guard in
|
||||||
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
|
// resolveWorkspacePath already gates the protocol handler).
|
||||||
// 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.
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{state.kind === 'loaded' && (
|
{state.kind === 'loaded' && (
|
||||||
<iframe
|
<iframe
|
||||||
key={path}
|
key={path}
|
||||||
srcDoc={state.html}
|
src={iframeSrc}
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
className="h-full w-full border-0 bg-white"
|
className="h-full w-full border-0 bg-white"
|
||||||
title="HTML preview"
|
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,25 +4,17 @@ import * as React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
ArrowUpRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsDownUp,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Globe,
|
Globe,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Mic,
|
Mic,
|
||||||
Network,
|
|
||||||
Pencil,
|
|
||||||
Radio,
|
|
||||||
SearchIcon,
|
|
||||||
SquarePen,
|
|
||||||
Table2,
|
|
||||||
Plug,
|
Plug,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
|
@ -32,12 +24,6 @@ import {
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -66,10 +52,8 @@ import {
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
@ -90,9 +74,7 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context"
|
|
||||||
import { ConnectorsPopover } from "@/components/connectors-popover"
|
import { ConnectorsPopover } from "@/components/connectors-popover"
|
||||||
import { HelpPopover } from "@/components/help-popover"
|
import { HelpPopover } from "@/components/help-popover"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
|
|
@ -108,6 +90,7 @@ interface TreeNode {
|
||||||
kind: "file" | "dir"
|
kind: "file" | "dir"
|
||||||
children?: TreeNode[]
|
children?: TreeNode[]
|
||||||
loaded?: boolean
|
loaded?: boolean
|
||||||
|
stat?: { size: number; mtimeMs: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
type KnowledgeActions = {
|
type KnowledgeActions = {
|
||||||
|
|
@ -115,6 +98,9 @@ type KnowledgeActions = {
|
||||||
createFolder: (parentPath?: string) => Promise<string>
|
createFolder: (parentPath?: string) => Promise<string>
|
||||||
openGraph: () => void
|
openGraph: () => void
|
||||||
openBases: () => void
|
openBases: () => void
|
||||||
|
openKnowledgeView: () => void
|
||||||
|
openWorkspaceAt: (path?: string) => void
|
||||||
|
createWorkspace: (name: string) => Promise<string>
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
collapseAll: () => void
|
collapseAll: () => void
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
|
|
@ -124,12 +110,11 @@ type KnowledgeActions = {
|
||||||
onOpenInNewTab?: (path: string) => void
|
onOpenInNewTab?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileManagerName(): string {
|
function displayNoteName(node: TreeNode): string {
|
||||||
if (typeof navigator === 'undefined') return 'File Manager'
|
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
||||||
const platform = navigator.platform.toLowerCase()
|
return node.name.slice(0, -3)
|
||||||
if (platform.includes('mac')) return 'Finder'
|
}
|
||||||
if (platform.includes('win')) return 'Explorer'
|
return node.name
|
||||||
return 'File Manager'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunListItem = {
|
type RunListItem = {
|
||||||
|
|
@ -203,19 +188,14 @@ type TasksActions = {
|
||||||
type SidebarContentPanelProps = {
|
type SidebarContentPanelProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
|
||||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||||
onToggleFolder?: (path: string) => void
|
|
||||||
knowledgeActions: KnowledgeActions
|
knowledgeActions: KnowledgeActions
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
|
||||||
runs?: RunListItem[]
|
runs?: RunListItem[]
|
||||||
currentRunId?: string | null
|
currentRunId?: string | null
|
||||||
processingRunIds?: Set<string>
|
processingRunIds?: Set<string>
|
||||||
tasksActions?: TasksActions
|
tasksActions?: TasksActions
|
||||||
backgroundTasks?: BackgroundTaskItem[]
|
backgroundTasks?: BackgroundTaskItem[]
|
||||||
selectedBackgroundTask?: string | null
|
selectedBackgroundTask?: string | null
|
||||||
onNewChat?: () => void
|
|
||||||
onOpenSearch?: () => void
|
|
||||||
isSearchOpen?: boolean
|
isSearchOpen?: boolean
|
||||||
isBrowserOpen?: boolean
|
isBrowserOpen?: boolean
|
||||||
onToggleBrowser?: () => void
|
onToggleBrowser?: () => void
|
||||||
|
|
@ -223,19 +203,12 @@ type SidebarContentPanelProps = {
|
||||||
onOpenSuggestedTopics?: () => void
|
onOpenSuggestedTopics?: () => void
|
||||||
isMeetingsOpen?: boolean
|
isMeetingsOpen?: boolean
|
||||||
onOpenMeetings?: () => void
|
onOpenMeetings?: () => void
|
||||||
isLiveNotesOpen?: boolean
|
|
||||||
onOpenLiveNotes?: () => void
|
|
||||||
isBgTasksOpen?: boolean
|
isBgTasksOpen?: boolean
|
||||||
onOpenBgTasks?: () => void
|
onOpenBgTasks?: () => void
|
||||||
isEmailOpen?: boolean
|
isEmailOpen?: boolean
|
||||||
onOpenEmail?: () => void
|
onOpenEmail?: () => void
|
||||||
} & React.ComponentProps<typeof Sidebar>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
|
||||||
{ id: "tasks", label: "Chat" },
|
|
||||||
{ id: "knowledge", label: "Knowledge" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatEventTime(ts: string): string {
|
function formatEventTime(ts: string): string {
|
||||||
const date = new Date(ts)
|
const date = new Date(ts)
|
||||||
if (Number.isNaN(date.getTime())) return ""
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
|
@ -464,19 +437,14 @@ function SyncStatusBar() {
|
||||||
export function SidebarContentPanel({
|
export function SidebarContentPanel({
|
||||||
tree,
|
tree,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
onToggleFolder,
|
|
||||||
knowledgeActions,
|
knowledgeActions,
|
||||||
onVoiceNoteCreated,
|
|
||||||
runs = [],
|
runs = [],
|
||||||
currentRunId,
|
currentRunId,
|
||||||
processingRunIds,
|
processingRunIds,
|
||||||
tasksActions,
|
tasksActions,
|
||||||
backgroundTasks = [],
|
backgroundTasks = [],
|
||||||
selectedBackgroundTask,
|
selectedBackgroundTask,
|
||||||
onNewChat,
|
|
||||||
onOpenSearch,
|
|
||||||
isSearchOpen = false,
|
isSearchOpen = false,
|
||||||
isBrowserOpen = false,
|
isBrowserOpen = false,
|
||||||
onToggleBrowser,
|
onToggleBrowser,
|
||||||
|
|
@ -484,15 +452,12 @@ export function SidebarContentPanel({
|
||||||
onOpenSuggestedTopics,
|
onOpenSuggestedTopics,
|
||||||
isMeetingsOpen = false,
|
isMeetingsOpen = false,
|
||||||
onOpenMeetings,
|
onOpenMeetings,
|
||||||
isLiveNotesOpen = false,
|
|
||||||
onOpenLiveNotes,
|
|
||||||
isBgTasksOpen = false,
|
isBgTasksOpen = false,
|
||||||
onOpenBgTasks,
|
onOpenBgTasks,
|
||||||
isEmailOpen = false,
|
isEmailOpen = false,
|
||||||
onOpenEmail,
|
onOpenEmail,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const { activeSection, setActiveSection } = useSidebarSection()
|
|
||||||
const [hasOauthError, setHasOauthError] = useState(false)
|
const [hasOauthError, setHasOauthError] = useState(false)
|
||||||
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
||||||
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
||||||
|
|
@ -505,7 +470,6 @@ export function SidebarContentPanel({
|
||||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
||||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||||
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
|
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
|
||||||
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
|
|
||||||
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
||||||
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
||||||
|
|
||||||
|
|
@ -570,97 +534,8 @@ export function SidebarContentPanel({
|
||||||
<SidebarHeader className="titlebar-drag-region">
|
<SidebarHeader className="titlebar-drag-region">
|
||||||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||||
<div className="h-8" />
|
<div className="h-8" />
|
||||||
{/* Tab switcher - centered below the traffic lights row */}
|
|
||||||
<div className="flex items-center px-2 py-1.5">
|
|
||||||
<div className="rowboat-section-switcher titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
|
|
||||||
{sectionTabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveSection(tab.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-md px-3 py-1 text-sm font-medium transition-colors",
|
|
||||||
activeSection === tab.id
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
|
||||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Quick action buttons */}
|
{/* Quick action buttons */}
|
||||||
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||||
{onNewChat && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNewChat}
|
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<SquarePen className="size-4" />
|
|
||||||
<span>New chat</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOpenSearch && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSearch}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
||||||
isSearchOpen
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4" />
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onToggleBrowser && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleBrowser}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
||||||
isBrowserQuickActionSelected
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Globe className="size-4" />
|
|
||||||
<span>Run browser task</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOpenSuggestedTopics && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSuggestedTopics}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
||||||
isSuggestedTopicsQuickActionSelected
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Lightbulb className="size-4" />
|
|
||||||
<span>Suggested Topics</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOpenBgTasks && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenBgTasks}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
|
||||||
isBgTasksQuickActionSelected
|
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ListChecks className="size-4" />
|
|
||||||
<span>Background tasks</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onOpenEmail && (
|
{onOpenEmail && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -691,45 +566,39 @@ export function SidebarContentPanel({
|
||||||
<span>Meetings</span>
|
<span>Meetings</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onOpenLiveNotes && (
|
{onOpenBgTasks && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenLiveNotes}
|
onClick={onOpenBgTasks}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||||
isLiveNotesQuickActionSelected
|
isBgTasksQuickActionSelected
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Radio className="size-4" />
|
<ListChecks className="size-4" />
|
||||||
<span>Live notes</span>
|
<span>Agents</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{activeSection === "knowledge" && (
|
<KnowledgeSection
|
||||||
<KnowledgeSection
|
tree={tree}
|
||||||
tree={tree}
|
selectedPath={selectedPath}
|
||||||
selectedPath={selectedPath}
|
onSelectFile={onSelectFile}
|
||||||
expandedPaths={expandedPaths}
|
actions={knowledgeActions}
|
||||||
onSelectFile={onSelectFile}
|
/>
|
||||||
onToggleFolder={onToggleFolder}
|
<WorkspaceSection tree={tree} actions={knowledgeActions} />
|
||||||
actions={knowledgeActions}
|
<TasksSection
|
||||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
runs={runs}
|
||||||
/>
|
currentRunId={currentRunId}
|
||||||
)}
|
processingRunIds={processingRunIds}
|
||||||
{activeSection === "tasks" && (
|
actions={tasksActions}
|
||||||
<TasksSection
|
backgroundTasks={backgroundTasks}
|
||||||
runs={runs}
|
selectedBackgroundTask={selectedBackgroundTask}
|
||||||
currentRunId={currentRunId}
|
/>
|
||||||
processingRunIds={processingRunIds}
|
|
||||||
actions={tasksActions}
|
|
||||||
backgroundTasks={backgroundTasks}
|
|
||||||
selectedBackgroundTask={selectedBackgroundTask}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{/* Billing / upgrade CTA or Log in CTA */}
|
{/* Billing / upgrade CTA or Log in CTA */}
|
||||||
{isRowboatConnected && billing ? (
|
{isRowboatConnected && billing ? (
|
||||||
|
|
@ -769,6 +638,43 @@ export function SidebarContentPanel({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Secondary quick actions (above bottom divider) */}
|
||||||
|
{(onToggleBrowser || onOpenSuggestedTopics) && (
|
||||||
|
<div className="px-2 pb-1">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{onToggleBrowser && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleBrowser}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors",
|
||||||
|
isBrowserQuickActionSelected
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="size-4" />
|
||||||
|
<span>Run browser task</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onOpenSuggestedTopics && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSuggestedTopics}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors",
|
||||||
|
isSuggestedTopicsQuickActionSelected
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Lightbulb className="size-4" />
|
||||||
|
<span>Suggested Topics</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Bottom actions */}
|
{/* Bottom actions */}
|
||||||
<div className="border-t border-sidebar-border px-2 py-2">
|
<div className="border-t border-sidebar-border px-2 py-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -886,7 +792,7 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise<string | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice Note Recording Button
|
// 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 [isRecording, setIsRecording] = React.useState(false)
|
||||||
const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false)
|
const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false)
|
||||||
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
|
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
|
||||||
|
|
@ -1102,163 +1008,69 @@ path: ${currentRelativePath}
|
||||||
function KnowledgeSection({
|
function KnowledgeSection({
|
||||||
tree,
|
tree,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
expandedPaths,
|
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
onToggleFolder,
|
|
||||||
actions,
|
actions,
|
||||||
onVoiceNoteCreated,
|
|
||||||
}: {
|
}: {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
|
||||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||||
onToggleFolder?: (path: string) => void
|
|
||||||
actions: KnowledgeActions
|
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 visibleTree = React.useMemo(
|
const visibleTree = React.useMemo(
|
||||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
|
() => tree.filter((item) => item.path !== 'knowledge/Meetings' && item.path !== 'knowledge/Workspace'),
|
||||||
[tree],
|
[tree],
|
||||||
)
|
)
|
||||||
|
const recentNotes = React.useMemo<TreeNode[]>(() => {
|
||||||
useEffect(() => {
|
const out: TreeNode[] = []
|
||||||
if (!selectedPath) return
|
const walk = (nodes: TreeNode[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
let cancelled = false
|
if (n.kind === 'file') out.push(n)
|
||||||
let rafId: number | null = null
|
else if (n.children?.length) walk(n.children)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
walk(visibleTree)
|
||||||
rafId = requestAnimationFrame(revealActiveFile)
|
return out
|
||||||
return () => {
|
.filter((n) => n.stat?.mtimeMs !== undefined)
|
||||||
cancelled = true
|
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
.slice(0, 3)
|
||||||
}
|
}, [visibleTree])
|
||||||
}, [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 (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
<SidebarGroup 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">
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{quickActions.map((action) => (
|
Knowledge
|
||||||
<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>
|
</div>
|
||||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
<SidebarGroupContent>
|
||||||
<div ref={treeContainerRef}>
|
<SidebarMenu>
|
||||||
<SidebarMenu>
|
{recentNotes.map((note) => (
|
||||||
{visibleTree.map((item, index) => (
|
<SidebarMenuItem key={note.path}>
|
||||||
<Tree
|
<SidebarMenuButton
|
||||||
key={index}
|
isActive={selectedPath === note.path}
|
||||||
item={item}
|
onClick={() => onSelectFile(note.path, 'file')}
|
||||||
selectedPath={selectedPath}
|
>
|
||||||
expandedPaths={expandedPaths}
|
<FileText className="size-4 shrink-0" />
|
||||||
onSelect={handleSelect}
|
<span className="truncate">{displayNoteName(note)}</span>
|
||||||
onToggleFolder={onToggleFolder}
|
</SidebarMenuButton>
|
||||||
actions={wrappedActions}
|
</SidebarMenuItem>
|
||||||
selectedFolderPath={selectedFolderPath}
|
))}
|
||||||
renameTarget={renameTarget}
|
<SidebarMenuItem>
|
||||||
onRenameTargetConsumed={() => setRenameTarget(null)}
|
<SidebarMenuButton onClick={() => actions.openKnowledgeView()}>
|
||||||
/>
|
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
))}
|
<span className="text-muted-foreground">View all</span>
|
||||||
</SidebarMenu>
|
</SidebarMenuButton>
|
||||||
</div>
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48">
|
<ContextMenuContent className="w-48">
|
||||||
<ContextMenuItem onClick={() => wrappedActions.createNote()}>
|
<ContextMenuItem onClick={() => actions.createNote()}>
|
||||||
<FilePlus className="mr-2 size-4" />
|
<FilePlus className="mr-2 size-4" />
|
||||||
New Note
|
New Note
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => void wrappedActions.createFolder()}>
|
<ContextMenuItem onClick={() => void actions.createFolder()}>
|
||||||
<FolderPlus className="mr-2 size-4" />
|
<FolderPlus className="mr-2 size-4" />
|
||||||
New Folder
|
New Folder
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
@ -1267,318 +1079,50 @@ function KnowledgeSection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function countFiles(node: TreeNode): number {
|
export function WorkspaceSection({
|
||||||
if (node.kind === 'file') return 1
|
tree,
|
||||||
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,
|
actions,
|
||||||
selectedFolderPath,
|
|
||||||
renameTarget,
|
|
||||||
onRenameTargetConsumed,
|
|
||||||
}: {
|
}: {
|
||||||
item: TreeNode
|
tree: TreeNode[]
|
||||||
selectedPath: string | null
|
|
||||||
expandedPaths: Set<string>
|
|
||||||
onSelect: (path: string, kind: "file" | "dir") => void
|
|
||||||
onToggleFolder?: (path: string) => void
|
|
||||||
actions: KnowledgeActions
|
actions: KnowledgeActions
|
||||||
selectedFolderPath?: string | null
|
|
||||||
renameTarget?: string | null
|
|
||||||
onRenameTargetConsumed?: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const isDir = item.kind === 'dir'
|
const recentWorkspaces = React.useMemo<TreeNode[]>(() => {
|
||||||
const isExpanded = expandedPaths.has(item.path)
|
const root = tree.find((item) => item.path === 'knowledge/Workspace')
|
||||||
const isSelected = selectedPath === item.path
|
const children = root?.children ?? []
|
||||||
const isFolderSelected = isDir && selectedFolderPath === item.path
|
return [...children]
|
||||||
const [isRenaming, setIsRenaming] = useState(false)
|
.filter((c) => c.kind === 'dir')
|
||||||
const isSubmittingRef = React.useRef(false)
|
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
||||||
const displayName = (isDir && FOLDER_DISPLAY_NAMES[item.name]) || item.name
|
.slice(0, 3)
|
||||||
|
}, [tree])
|
||||||
// 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 (
|
return (
|
||||||
<ContextMenu>
|
<SidebarGroup className="flex flex-col">
|
||||||
<ContextMenuTrigger asChild>
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
<SidebarMenuItem>
|
Workspace
|
||||||
<Collapsible
|
</div>
|
||||||
open={isExpanded}
|
<SidebarGroupContent>
|
||||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
<SidebarMenu>
|
||||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
{recentWorkspaces.map((ws) => (
|
||||||
>
|
<SidebarMenuItem key={ws.path}>
|
||||||
<CollapsibleTrigger asChild>
|
<SidebarMenuButton onClick={() => actions.openWorkspaceAt(ws.path)}>
|
||||||
<SidebarMenuButton isActive={isFolderSelected}>
|
<Folder className="size-4 shrink-0" />
|
||||||
<ChevronRight className="transition-transform size-4" />
|
<span className="truncate">{ws.name}</span>
|
||||||
<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>
|
</SidebarMenuButton>
|
||||||
</CollapsibleTrigger>
|
</SidebarMenuItem>
|
||||||
<CollapsibleContent>
|
))}
|
||||||
<SidebarMenuSub>
|
<SidebarMenuItem>
|
||||||
{(item.children ?? []).map((subItem, index) => (
|
<SidebarMenuButton onClick={() => actions.openWorkspaceAt()}>
|
||||||
<Tree
|
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
key={index}
|
<span className="text-muted-foreground">View all</span>
|
||||||
item={subItem}
|
</SidebarMenuButton>
|
||||||
selectedPath={selectedPath}
|
</SidebarMenuItem>
|
||||||
expandedPaths={expandedPaths}
|
</SidebarMenu>
|
||||||
onSelect={onSelect}
|
</SidebarGroupContent>
|
||||||
onToggleFolder={onToggleFolder}
|
</SidebarGroup>
|
||||||
actions={actions}
|
|
||||||
selectedFolderPath={selectedFolderPath}
|
|
||||||
renameTarget={renameTarget}
|
|
||||||
onRenameTargetConsumed={onRenameTargetConsumed}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
{contextMenuContent}
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Get status indicator color
|
// Get status indicator color
|
||||||
function getStatusColor(status?: string, enabled?: boolean): string {
|
function getStatusColor(status?: string, enabled?: boolean): string {
|
||||||
// Disabled agents always show gray
|
// Disabled agents always show gray
|
||||||
|
|
@ -1619,8 +1163,8 @@ function TasksSection({
|
||||||
const [pendingDeleteRunId, setPendingDeleteRunId] = useState<string | null>(null)
|
const [pendingDeleteRunId, setPendingDeleteRunId] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
<SidebarGroup className="flex flex-col">
|
||||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
<SidebarGroupContent>
|
||||||
{/* Background Tasks Section */}
|
{/* Background Tasks Section */}
|
||||||
{backgroundTasks.length > 0 && (
|
{backgroundTasks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
262
apps/x/apps/renderer/src/components/workspace-view.tsx
Normal file
262
apps/x/apps/renderer/src/components/workspace-view.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ChevronRight, File as FileIcon, Folder as FolderIcon, Home, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
kind: 'file' | 'dir'
|
||||||
|
children?: TreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceViewProps = {
|
||||||
|
tree: TreeNode[]
|
||||||
|
initialPath?: string | null
|
||||||
|
onOpenNote: (path: string) => void
|
||||||
|
onCreateWorkspace: (name: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
|
||||||
|
if (!nodes) return null
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path === path) return node
|
||||||
|
if (node.kind === 'dir' && path.startsWith(`${node.path}/`)) {
|
||||||
|
const found = findNode(node.children, path)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function countChildren(node: TreeNode | null): number {
|
||||||
|
if (!node || node.kind !== 'dir' || !node.children) return 0
|
||||||
|
return node.children.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialPath) setCurrentPath(initialPath)
|
||||||
|
}, [initialPath])
|
||||||
|
|
||||||
|
const isRoot = currentPath === WORKSPACE_ROOT
|
||||||
|
|
||||||
|
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
|
||||||
|
|
||||||
|
const items = useMemo<TreeNode[]>(() => {
|
||||||
|
const children = currentNode?.children ?? []
|
||||||
|
const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
}, [currentNode, isRoot])
|
||||||
|
|
||||||
|
const breadcrumbs = useMemo(() => {
|
||||||
|
if (isRoot) return [] as { path: string; name: string }[]
|
||||||
|
const rel = currentPath.slice(WORKSPACE_ROOT.length + 1)
|
||||||
|
const parts = rel.split('/').filter(Boolean)
|
||||||
|
let acc = WORKSPACE_ROOT
|
||||||
|
return parts.map((seg) => {
|
||||||
|
acc = `${acc}/${seg}`
|
||||||
|
return { path: acc, name: seg }
|
||||||
|
})
|
||||||
|
}, [currentPath, isRoot])
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(item: TreeNode) => {
|
||||||
|
if (item.kind === 'dir') {
|
||||||
|
setCurrentPath(item.path)
|
||||||
|
} else {
|
||||||
|
onOpenNote(item.path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpenNote],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetAddDialog = useCallback(() => {
|
||||||
|
setNewName('')
|
||||||
|
setError(null)
|
||||||
|
setCreating(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
const trimmed = newName.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmed.includes('/')) {
|
||||||
|
setError('Name cannot contain "/"')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onCreateWorkspace(trimmed)
|
||||||
|
setAddOpen(false)
|
||||||
|
resetAddDialog()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}, [newName, onCreateWorkspace, resetAddDialog])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPath(WORKSPACE_ROOT)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
|
||||||
|
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Home className="size-4" />
|
||||||
|
<span className="font-medium">Workspace</span>
|
||||||
|
</button>
|
||||||
|
{breadcrumbs.map((crumb, idx) => {
|
||||||
|
const isLast = idx === breadcrumbs.length - 1
|
||||||
|
return (
|
||||||
|
<span key={crumb.path} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground/60" />
|
||||||
|
{isLast ? (
|
||||||
|
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
|
||||||
|
{crumb.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPath(crumb.path)}
|
||||||
|
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
||||||
|
>
|
||||||
|
{crumb.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isRoot && (
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add workspace
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
||||||
|
<FolderIcon className="size-10 opacity-50" />
|
||||||
|
<div className="text-sm">
|
||||||
|
{isRoot
|
||||||
|
? 'No workspaces yet. Create one to get started.'
|
||||||
|
: 'This folder is empty.'}
|
||||||
|
</div>
|
||||||
|
{isRoot && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add workspace
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const childCount = item.kind === 'dir' ? countChildren(item) : 0
|
||||||
|
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
className="group flex flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
|
||||||
|
<div className="min-w-0 w-full">
|
||||||
|
<div className="truncate text-sm font-medium">{item.name}</div>
|
||||||
|
{item.kind === 'dir' && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{childCount} {childCount === 1 ? 'item' : 'items'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setAddOpen(open)
|
||||||
|
if (!open) resetAddDialog()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New workspace</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Workspaces are top-level folders inside knowledge/Workspace.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="workspace-name" className="text-sm font-medium">Name</label>
|
||||||
|
<Input
|
||||||
|
id="workspace-name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="e.g. Alpha"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !creating) {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleCreate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setAddOpen(false)
|
||||||
|
resetAddDialog()
|
||||||
|
}}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue