mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
add workspaces
This commit is contained in:
parent
48eb9214c8
commit
c36f17130f
3 changed files with 765 additions and 243 deletions
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
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 { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -25,6 +25,7 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
|||
import { LiveNotesView } from '@/components/live-notes-view';
|
||||
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||
import { EmailView } from '@/components/email-view';
|
||||
import { WorkspaceView } from '@/components/workspace-view';
|
||||
import { MeetingsView } from '@/components/meetings-view';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
|
|
@ -182,6 +183,8 @@ const MEETINGS_TAB_PATH = '__rowboat_meetings__'
|
|||
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
|
||||
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
|
||||
const EMAIL_TAB_PATH = '__rowboat_email__'
|
||||
const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
|
||||
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
|
|
@ -315,6 +318,7 @@ const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
|
|||
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
|
||||
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
|
||||
const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
|
||||
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
|
||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
|
|
@ -565,12 +569,14 @@ type ViewState =
|
|||
| { type: 'meetings' }
|
||||
| { type: 'live-notes' }
|
||||
| { type: 'email' }
|
||||
| { type: 'workspace'; path?: string }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId
|
||||
if (a.type === 'file' && b.type === 'file') return a.path === b.path
|
||||
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
||||
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
|
||||
return true // both graph
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +620,10 @@ function parseDeepLink(input: string): ViewState | null {
|
|||
return { type: 'meetings' }
|
||||
case 'live-notes':
|
||||
return { type: 'live-notes' }
|
||||
case 'workspace': {
|
||||
const path = params.get('path')
|
||||
return { type: 'workspace', path: path ?? undefined }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
@ -623,9 +633,11 @@ function parseDeepLink(input: string): ViewState | null {
|
|||
function FixedSidebarToggle({
|
||||
leftInsetPx,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
}: {
|
||||
leftInsetPx: number
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
return (
|
||||
|
|
@ -652,6 +664,17 @@ function FixedSidebarToggle({
|
|||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -736,6 +759,8 @@ function App() {
|
|||
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
|
||||
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
|
||||
const [isEmailOpen, setIsEmailOpen] = useState(false)
|
||||
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
|
||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{
|
||||
path: string | null
|
||||
graph: boolean
|
||||
|
|
@ -1072,6 +1097,7 @@ function App() {
|
|||
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
|
||||
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
|
||||
if (isEmailTabPath(tab.path)) return 'Email'
|
||||
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -2784,7 +2810,7 @@ function App() {
|
|||
setActiveFileTabId(existingTab.id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedPath(path)
|
||||
return
|
||||
}
|
||||
|
|
@ -2793,7 +2819,7 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedPath(path)
|
||||
}, [fileTabs, dismissBrowserOverlay])
|
||||
|
||||
|
|
@ -2812,14 +2838,14 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
return
|
||||
}
|
||||
if (isLiveNotesTabPath(tab.path)) {
|
||||
|
|
@ -2829,6 +2855,7 @@ function App() {
|
|||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -2838,6 +2865,8 @@ function App() {
|
|||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsBgTasksOpen(true)
|
||||
return
|
||||
}
|
||||
|
|
@ -2848,26 +2877,41 @@ function App() {
|
|||
setIsMeetingsOpen(true)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
return
|
||||
}
|
||||
if (isEmailTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
return
|
||||
}
|
||||
if (isWorkspaceTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(true)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
|
|
@ -2890,7 +2934,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2904,12 +2948,12 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2918,6 +2962,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2925,6 +2970,7 @@ function App() {
|
|||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
|
|
@ -2934,6 +2980,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(true)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
} else if (isEmailTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2941,11 +2988,21 @@ function App() {
|
|||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
} else if (isWorkspaceTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -2976,7 +3033,7 @@ function App() {
|
|||
dismissBrowserOverlay()
|
||||
handleNewChat()
|
||||
// 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) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
|
|
@ -2993,8 +3050,8 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -3126,7 +3183,7 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// Remember where we came from so the close button can return
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) {
|
||||
setExpandedFrom({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
|
|
@ -3142,19 +3199,19 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, dismissBrowserOverlay])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
} else if (expandedFrom.meetings) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
|
|
@ -3186,7 +3243,7 @@ function App() {
|
|||
} else if (expandedFrom.path) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -3200,10 +3257,11 @@ function App() {
|
|||
if (isMeetingsOpen) return { type: 'meetings' }
|
||||
if (isLiveNotesOpen) return { type: 'live-notes' }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, workspaceInitialPath, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -3304,6 +3362,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [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 openEmailView = useCallback(() => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3312,6 +3381,7 @@ function App() {
|
|||
setIsMeetingsOpen(false)
|
||||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3324,7 +3394,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3341,6 +3411,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3356,7 +3427,7 @@ function App() {
|
|||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -3371,7 +3442,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -3384,7 +3455,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
|
|
@ -3397,7 +3468,7 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
return
|
||||
case 'meetings':
|
||||
|
|
@ -3412,6 +3483,7 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
ensureMeetingsFileTab()
|
||||
return
|
||||
case 'live-notes':
|
||||
|
|
@ -3425,6 +3497,7 @@ function App() {
|
|||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsWorkspaceOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
ensureLiveNotesFileTab()
|
||||
return
|
||||
|
|
@ -3440,8 +3513,25 @@ function App() {
|
|||
setIsLiveNotesOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(true)
|
||||
setIsWorkspaceOpen(false)
|
||||
ensureEmailFileTab()
|
||||
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)
|
||||
setWorkspaceInitialPath(view.path ?? null)
|
||||
ensureWorkspaceFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3450,7 +3540,7 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -3458,7 +3548,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -3780,7 +3870,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// 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 && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3865,11 +3955,11 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen)
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && isChatSidebarOpen)
|
||||
const targetPane: ShortcutPane = rightPaneAvailable
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: '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)
|
||||
const selectedKnowledgePath = isGraphOpen
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
|
|
@ -3882,6 +3972,8 @@ function App() {
|
|||
? BG_TASKS_TAB_PATH
|
||||
: isEmailOpen
|
||||
? EMAIL_TAB_PATH
|
||||
: isWorkspaceOpen
|
||||
? WORKSPACE_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
|
|
@ -3936,7 +4028,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('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, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -3961,7 +4053,7 @@ function App() {
|
|||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -4087,19 +4179,42 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// 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 && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
},
|
||||
openWorkspaceAt: (path?: string) => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'workspace', path })
|
||||
},
|
||||
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))),
|
||||
collapseAll: () => setExpandedPaths(new Set()),
|
||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||
|
|
@ -4690,7 +4805,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4707,7 +4822,7 @@ function App() {
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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) {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
}}>
|
||||
|
|
@ -4740,7 +4855,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4751,7 +4866,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4775,14 +4890,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4800,8 +4915,6 @@ function App() {
|
|||
}}
|
||||
backgroundTasks={backgroundTasks}
|
||||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
|
|
@ -4809,8 +4922,6 @@ function App() {
|
|||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
isMeetingsOpen={isMeetingsOpen}
|
||||
onOpenMeetings={openMeetingsView}
|
||||
isLiveNotesOpen={isLiveNotesOpen}
|
||||
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
|
||||
isBgTasksOpen={isBgTasksOpen}
|
||||
onOpenBgTasks={openBgTasksView}
|
||||
isEmailOpen={isEmailOpen}
|
||||
|
|
@ -4834,7 +4945,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4842,7 +4953,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
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 || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -4895,7 +5006,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4910,7 +5021,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4925,7 +5036,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4991,6 +5102,15 @@ function App() {
|
|||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<EmailView />
|
||||
</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>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
@ -5414,6 +5534,7 @@ function App() {
|
|||
<FixedSidebarToggle
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import {
|
|||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Radio,
|
||||
SearchIcon,
|
||||
LayoutGrid,
|
||||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
|
|
@ -91,7 +90,6 @@ import {
|
|||
} from "@/components/ui/context-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context"
|
||||
import { ConnectorsPopover } from "@/components/connectors-popover"
|
||||
import { HelpPopover } from "@/components/help-popover"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
|
|
@ -114,6 +112,8 @@ type KnowledgeActions = {
|
|||
createFolder: (parentPath?: string) => Promise<string>
|
||||
openGraph: () => void
|
||||
openBases: () => void
|
||||
openWorkspaceAt: (path?: string) => void
|
||||
createWorkspace: (name: string) => Promise<string>
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
|
|
@ -213,8 +213,6 @@ type SidebarContentPanelProps = {
|
|||
tasksActions?: TasksActions
|
||||
backgroundTasks?: BackgroundTaskItem[]
|
||||
selectedBackgroundTask?: string | null
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
isSearchOpen?: boolean
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
|
|
@ -222,19 +220,12 @@ type SidebarContentPanelProps = {
|
|||
onOpenSuggestedTopics?: () => void
|
||||
isMeetingsOpen?: boolean
|
||||
onOpenMeetings?: () => void
|
||||
isLiveNotesOpen?: boolean
|
||||
onOpenLiveNotes?: () => void
|
||||
isBgTasksOpen?: boolean
|
||||
onOpenBgTasks?: () => void
|
||||
isEmailOpen?: boolean
|
||||
onOpenEmail?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
{ id: "tasks", label: "Chat" },
|
||||
{ id: "knowledge", label: "Knowledge" },
|
||||
]
|
||||
|
||||
function formatEventTime(ts: string): string {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return ""
|
||||
|
|
@ -474,8 +465,6 @@ export function SidebarContentPanel({
|
|||
tasksActions,
|
||||
backgroundTasks = [],
|
||||
selectedBackgroundTask,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
isSearchOpen = false,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
|
|
@ -483,15 +472,12 @@ export function SidebarContentPanel({
|
|||
onOpenSuggestedTopics,
|
||||
isMeetingsOpen = false,
|
||||
onOpenMeetings,
|
||||
isLiveNotesOpen = false,
|
||||
onOpenLiveNotes,
|
||||
isBgTasksOpen = false,
|
||||
onOpenBgTasks,
|
||||
isEmailOpen = false,
|
||||
onOpenEmail,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
const [hasOauthError, setHasOauthError] = useState(false)
|
||||
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
||||
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
||||
|
|
@ -504,7 +490,6 @@ export function SidebarContentPanel({
|
|||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
|
||||
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
|
||||
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
||||
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
||||
|
||||
|
|
@ -569,87 +554,8 @@ export function SidebarContentPanel({
|
|||
<SidebarHeader className="titlebar-drag-region">
|
||||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||
<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 */}
|
||||
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||
{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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -680,45 +586,41 @@ export function SidebarContentPanel({
|
|||
<span>Meetings</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenLiveNotes && (
|
||||
{onOpenBgTasks && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLiveNotes}
|
||||
onClick={onOpenBgTasks}
|
||||
className={cn(
|
||||
"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"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Radio className="size-4" />
|
||||
<span>Live notes</span>
|
||||
<ListChecks className="size-4" />
|
||||
<span>Agents</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{activeSection === "knowledge" && (
|
||||
<KnowledgeSection
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={knowledgeActions}
|
||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "tasks" && (
|
||||
<TasksSection
|
||||
runs={runs}
|
||||
currentRunId={currentRunId}
|
||||
processingRunIds={processingRunIds}
|
||||
actions={tasksActions}
|
||||
backgroundTasks={backgroundTasks}
|
||||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
/>
|
||||
)}
|
||||
<KnowledgeSection
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={knowledgeActions}
|
||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||
/>
|
||||
<TasksSection
|
||||
runs={runs}
|
||||
currentRunId={currentRunId}
|
||||
processingRunIds={processingRunIds}
|
||||
actions={tasksActions}
|
||||
backgroundTasks={backgroundTasks}
|
||||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
/>
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{isRowboatConnected && billing ? (
|
||||
|
|
@ -758,6 +660,43 @@ export function SidebarContentPanel({
|
|||
</button>
|
||||
</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 */}
|
||||
<div className="border-t border-sidebar-border px-2 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
@ -1109,10 +1048,18 @@ function KnowledgeSection({
|
|||
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
const [knowledgeTab, setKnowledgeTab] = useState<'notes' | 'workspaces'>('notes')
|
||||
const visibleTree = React.useMemo(
|
||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
|
||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings' && item.path !== 'knowledge/Workspace'),
|
||||
[tree],
|
||||
)
|
||||
const workspaceFolders = React.useMemo<TreeNode[]>(() => {
|
||||
const root = tree.find((item) => item.path === 'knowledge/Workspace')
|
||||
const children = root?.children ?? []
|
||||
return [...children]
|
||||
.filter((c) => c.kind === 'dir')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [tree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath) return
|
||||
|
|
@ -1184,75 +1131,267 @@ function KnowledgeSection({
|
|||
]
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-center gap-1 py-1 sticky top-0 z-10 bg-sidebar border-b border-sidebar-border">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={isExpanded ? actions.collapseAll : actions.expandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||
<div ref={treeContainerRef}>
|
||||
<SidebarMenu>
|
||||
{visibleTree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={handleSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={wrappedActions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={() => setRenameTarget(null)}
|
||||
/>
|
||||
<SidebarGroup className="flex flex-col">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Knowledge
|
||||
</div>
|
||||
<div className="px-2 pb-1">
|
||||
<div className="flex w-full rounded-md bg-sidebar-accent/40 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKnowledgeTab('notes')}
|
||||
className={cn(
|
||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||
knowledgeTab === 'notes'
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKnowledgeTab('workspaces')}
|
||||
className={cn(
|
||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||
knowledgeTab === 'workspaces'
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
Workspaces
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeTab === 'notes' ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-center gap-1 py-1 sticky top-0 z-10 bg-sidebar border-b border-sidebar-border">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={isExpanded ? actions.collapseAll : actions.expandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<div ref={treeContainerRef}>
|
||||
<SidebarMenu>
|
||||
{visibleTree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={handleSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={wrappedActions}
|
||||
selectedFolderPath={selectedFolderPath}
|
||||
renameTarget={renameTarget}
|
||||
onRenameTargetConsumed={() => setRenameTarget(null)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => wrappedActions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void wrappedActions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => wrappedActions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void wrappedActions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<WorkspacesPanel
|
||||
workspaces={workspaceFolders}
|
||||
onOpen={(path) => actions.openWorkspaceAt(path)}
|
||||
onOpenRoot={() => actions.openWorkspaceAt()}
|
||||
onCreate={actions.createWorkspace}
|
||||
onRevealInFileManager={() => actions.revealInFileManager('knowledge/Workspace', true)}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspacesPanel({
|
||||
workspaces,
|
||||
onOpen,
|
||||
onOpenRoot,
|
||||
onCreate,
|
||||
onRevealInFileManager,
|
||||
}: {
|
||||
workspaces: TreeNode[]
|
||||
onOpen: (path: string) => void
|
||||
onOpenRoot: () => void
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRevealInFileManager: () => void
|
||||
}) {
|
||||
const fileManagerName = getFileManagerName()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const resetDialog = useCallback(() => {
|
||||
setNewName('')
|
||||
setError(null)
|
||||
setCreating(false)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const newPath = await onCreate(newName)
|
||||
setAddOpen(false)
|
||||
resetDialog()
|
||||
onOpen(newPath)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
||||
setCreating(false)
|
||||
}
|
||||
}, [newName, onCreate, onOpen, resetDialog])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-1 py-1 sticky top-0 z-10 bg-sidebar border-b border-sidebar-border">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<FolderPlus className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Add workspace</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRoot}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Workspace view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevealInFileManager}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Open in {fileManagerName}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-sidebar-foreground/60">
|
||||
No workspaces yet.
|
||||
</div>
|
||||
) : (
|
||||
workspaces.map((ws) => (
|
||||
<SidebarMenuItem key={ws.path}>
|
||||
<SidebarMenuButton onClick={() => onOpen(ws.path)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<span className="truncate">{ws.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddOpen(open)
|
||||
if (!open) resetDialog()
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Workspaces are top-level folders inside knowledge/Workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="sidebar-workspace-name" className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
id="sidebar-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)
|
||||
resetDialog()
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1608,8 +1747,8 @@ function TasksSection({
|
|||
const [pendingDeleteRunId, setPendingDeleteRunId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||
<SidebarGroup className="flex flex-col">
|
||||
<SidebarGroupContent>
|
||||
{/* Background Tasks Section */}
|
||||
{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