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:
Arjun 2026-05-22 15:46:33 +05:30 committed by arkml
parent 5be0a11f98
commit 346c685ac9
6 changed files with 1139 additions and 704 deletions

View file

@ -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>

View file

@ -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', {

View file

@ -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"

View 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>
)
}

View file

@ -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 && (
<> <>

View 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>
)
}