diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..66846b73 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -25,6 +25,9 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; +import { WorkspaceView } from '@/components/workspace-view'; +import { KnowledgeView } from '@/components/knowledge-view'; +import { ChatHistoryView } from '@/components/chat-history-view'; import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { @@ -182,6 +185,10 @@ const MEETINGS_TAB_PATH = '__rowboat_meetings__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const EMAIL_TAB_PATH = '__rowboat_email__' +const WORKSPACE_TAB_PATH = '__rowboat_workspace__' +const WORKSPACE_ROOT = 'knowledge/Workspace' +const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' +const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -315,6 +322,9 @@ const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH +const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH +const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH +const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -565,12 +575,16 @@ type ViewState = | { type: 'meetings' } | { type: 'live-notes' } | { type: 'email' } + | { type: 'workspace'; path?: string } + | { type: 'knowledge-view' } + | { type: 'chat-history' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false if (a.type === 'chat' && b.type === 'chat') return a.runId === b.runId if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name + if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') return true // both graph } @@ -614,6 +628,14 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } + case 'workspace': { + const path = params.get('path') + return { type: 'workspace', path: path ?? undefined } + } + case 'knowledge-view': + return { type: 'knowledge-view' } + case 'chat-history': + return { type: 'chat-history' } default: return null } @@ -622,12 +644,16 @@ function parseDeepLink(input: string): ViewState | null { /** Sidebar toggle (fixed position, top-left) */ function FixedSidebarToggle({ leftInsetPx, + onNewChat, + onOpenSearch, }: { leftInsetPx: number + onNewChat?: () => void + onOpenSearch?: () => void }) { const { toggleSidebar } = useSidebar() return ( -
+
) } @@ -723,6 +771,12 @@ function App() { const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [isEmailOpen, setIsEmailOpen] = useState(false) + const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) + const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) + const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) + const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) + const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null graph: boolean @@ -1059,6 +1113,9 @@ function App() { if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isEmailTabPath(tab.path)) return 'Email' + if (isWorkspaceTabPath(tab.path)) return 'Workspace' + if (isKnowledgeViewTabPath(tab.path)) return 'Knowledge' + if (isChatHistoryTabPath(tab.path)) return 'Chat history' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -1347,6 +1404,13 @@ function App() { loadBackgroundTasks() } + // Reload bg-task summaries if anything under bg-tasks/ changed + if ( + eventPaths.some((p) => p === 'bg-tasks' || p.startsWith('bg-tasks/')) + ) { + loadBgTaskSummaries() + } + // Invalidate cached content for files changed outside the active editor. // This prevents stale backlinks after rename-rewrite passes touch many files. for (const path of eventPaths) { @@ -1692,6 +1756,37 @@ function App() { loadRuns() }, [loadRuns]) + const [bgTaskSummaries, setBgTaskSummaries] = useState>([]) + const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState(null) + const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0) + + const loadBgTaskSummaries = useCallback(async () => { + try { + const result = await window.ipc.invoke('bg-task:list', { limit: 200 }) + setBgTaskSummaries(result.items.map((it) => ({ + slug: it.slug, + name: it.name, + active: it.active, + createdAt: it.createdAt, + lastAttemptAt: it.lastAttemptAt, + lastRunAt: it.lastRunAt, + }))) + } catch (err) { + console.error('Failed to load bg-task summaries:', err) + } + }, []) + + useEffect(() => { + loadBgTaskSummaries() + }, [loadBgTaskSummaries]) + // Load background tasks const loadBackgroundTasks = useCallback(async () => { try { @@ -2771,7 +2866,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(path) return } @@ -2780,7 +2875,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2799,14 +2894,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) return } if (isLiveNotesTabPath(tab.path)) { @@ -2816,6 +2911,9 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) return } @@ -2825,6 +2923,10 @@ function App() { setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false) setIsLiveNotesOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsBgTasksOpen(true) return } @@ -2835,26 +2937,73 @@ function App() { setIsMeetingsOpen(true) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) return } if (isEmailTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsEmailOpen(true) return } + if (isWorkspaceTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) + setIsWorkspaceOpen(true) + return + } + if (isKnowledgeViewTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsChatHistoryOpen(false) + setIsKnowledgeViewOpen(true) + return + } + if (isChatHistoryTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(true) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -2877,7 +3026,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2891,12 +3040,12 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (isMeetingsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2905,6 +3054,9 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2912,6 +3064,9 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) } else if (isBgTasksTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -2921,6 +3076,9 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(true) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) } else if (isEmailTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -2928,11 +3086,47 @@ function App() { setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsEmailOpen(true) + } else if (isWorkspaceTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) + setIsWorkspaceOpen(true) + } else if (isKnowledgeViewTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsChatHistoryOpen(false) + setIsKnowledgeViewOpen(true) + } else if (isChatHistoryTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2963,7 +3157,7 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -2980,8 +3174,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -3113,7 +3307,7 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3129,19 +3323,19 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (expandedFrom.meetings) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) @@ -3173,7 +3367,7 @@ function App() { } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3187,10 +3381,13 @@ function App() { if (isMeetingsOpen) return { type: 'meetings' } if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } + if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } + if (isKnowledgeViewOpen) return { type: 'knowledge-view' } + if (isChatHistoryOpen) return { type: 'chat-history' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3291,6 +3488,39 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureWorkspaceFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isWorkspaceTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: WORKSPACE_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + + const 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 ensureChatHistoryFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isChatHistoryTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: CHAT_HISTORY_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const openEmailView = useCallback(() => { setSelectedPath(null) setIsGraphOpen(false) @@ -3299,6 +3529,8 @@ function App() { setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3311,7 +3543,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3328,6 +3560,8 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3343,7 +3577,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3358,7 +3592,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3371,7 +3605,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3384,7 +3618,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) ensureSuggestedTopicsFileTab() return case 'meetings': @@ -3399,6 +3633,9 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) ensureMeetingsFileTab() return case 'live-notes': @@ -3412,6 +3649,9 @@ function App() { setIsMeetingsOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(false) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) ensureLiveNotesFileTab() return @@ -3427,8 +3667,63 @@ function App() { setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(true) + setIsWorkspaceOpen(false) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) ensureEmailFileTab() return + case 'workspace': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setIsWorkspaceOpen(true) + setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(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) + setIsChatHistoryOpen(false) + ensureKnowledgeViewFileTab() + return + case 'chat-history': + 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(false) + setIsChatHistoryOpen(true) + ensureChatHistoryFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3437,7 +3732,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3445,7 +3740,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3767,7 +4062,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3795,6 +4090,18 @@ function App() { 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). useEffect(() => { const handleHistoryKeyDown = (e: KeyboardEvent) => { @@ -3840,11 +4147,11 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen @@ -3857,6 +4164,12 @@ function App() { ? BG_TASKS_TAB_PATH : isEmailOpen ? EMAIL_TAB_PATH + : isWorkspaceOpen + ? WORKSPACE_TAB_PATH + : isKnowledgeViewOpen + ? KNOWLEDGE_VIEW_TAB_PATH + : isChatHistoryOpen + ? CHAT_HISTORY_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3911,7 +4224,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3936,7 +4249,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4062,19 +4375,49 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) }, + openWorkspaceAt: (path?: string) => { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'workspace', path }) + }, + openKnowledgeView: () => { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'knowledge-view' }) + }, + createWorkspace: async (name: string): Promise => { + const trimmed = name.trim() + if (!trimmed) throw new Error('Name is required') + if (trimmed.includes('/')) throw new Error('Name cannot contain "/"') + const rootExists = await window.ipc.invoke('workspace:exists', { path: WORKSPACE_ROOT }) + if (!rootExists.exists) { + await window.ipc.invoke('workspace:mkdir', { path: WORKSPACE_ROOT, recursive: true }) + } + const target = `${WORKSPACE_ROOT}/${trimmed}` + const exists = await window.ipc.invoke('workspace:exists', { path: target }) + if (exists.exists) { + throw new Error(`A workspace named "${trimmed}" already exists`) + } + await window.ipc.invoke('workspace:mkdir', { path: target, recursive: true }) + return target + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -4226,12 +4569,14 @@ function App() { const pendingCalendarEventRef = useRef(undefined) const [meetingSummarizing, setMeetingSummarizing] = useState(false) const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) + const [recordingMeetingSource, setRecordingMeetingSource] = useState(null) const [checkingPermission, setCheckingPermission] = useState(false) const startMeetingNow = useCallback(async () => { const calEvent = pendingCalendarEventRef.current pendingCalendarEventRef.current = undefined + setRecordingMeetingSource(calEvent?.source ?? null) const notePath = await meetingTranscription.start(calEvent) if (notePath) { meetingNotePathRef.current = notePath @@ -4259,6 +4604,7 @@ function App() { const handleToggleMeeting = useCallback(async () => { if (meetingTranscription.state === 'recording') { await meetingTranscription.stop() + setRecordingMeetingSource(null) // Read the final transcript and generate meeting notes via LLM const notePath = meetingNotePathRef.current @@ -4665,7 +5011,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4682,7 +5028,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4696,18 +5042,8 @@ function App() { { - setExpandedPaths((prev) => { - const next = new Set(prev) - if (next.has(path)) next.delete(path) - else next.add(path) - return next - }) - }} knowledgeActions={knowledgeActions} - onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} currentRunId={runId} processingRunIds={processingRunIds} @@ -4715,7 +5051,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4726,7 +5062,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4750,14 +5086,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4772,24 +5108,31 @@ function App() { onSelectBackgroundTask: (taskName) => { void navigateToView({ type: 'task', name: taskName }) }, + onOpenChatHistoryView: () => { + void navigateToView({ type: 'chat-history' }) + }, + }} + bgTaskSummaries={bgTaskSummaries} + onOpenBgTask={(slug) => { + setBgTaskInitialSlug(slug) + setBgTaskSlugVersion((v) => v + 1) + openBgTasksView() }} - backgroundTasks={backgroundTasks} - selectedBackgroundTask={selectedBackgroundTask} - onNewChat={handleNewChatTab} - onOpenSearch={() => setIsSearchOpen(true)} isSearchOpen={isSearchOpen} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} - isMeetingsOpen={isMeetingsOpen} onOpenMeetings={openMeetingsView} - isLiveNotesOpen={isLiveNotesOpen} - onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} - isBgTasksOpen={isBgTasksOpen} - onOpenBgTasks={openBgTasksView} - isEmailOpen={isEmailOpen} - onOpenEmail={openEmailView} + meetingRecordingState={meetingTranscription.state} + recordingMeetingSource={recordingMeetingSource} + onToggleMeetingRecording={() => { void handleToggleMeeting() }} + onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} + onOpenEmail={(threadId) => { + setEmailInitialThreadId(threadId ?? null) + setEmailThreadIdVersion((v) => v + 1) + openEmailView() + }} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
) : isEmailOpen ? (
- + +
+ ) : isWorkspaceOpen ? ( +
+ navigateToFile(path)} + onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} + /> +
+ ) : isKnowledgeViewOpen ? ( +
+ navigateToFile(path)} + onOpenGraph={() => knowledgeActions.openGraph()} + onOpenSearch={() => setIsSearchOpen(true)} + onOpenBases={() => knowledgeActions.openBases()} + onVoiceNoteCreated={handleVoiceNoteCreated} + /> +
+ ) : isChatHistoryOpen ? ( +
+ void navigateToView({ type: 'chat', runId: rid })} + onOpenInNewTab={(rid) => openChatInNewTab(rid)} + onDeleteRun={async (rid) => { + try { + await window.ipc.invoke('runs:delete', { runId: rid }) + await loadRuns() + } catch (err) { + console.error('Failed to delete run:', err) + } + }} + onNewChat={handleNewChatTab} + onOpenSearch={() => setIsSearchOpen(true)} + />
) : selectedPath && isBaseFilePath(selectedPath) ? (
@@ -5388,6 +5782,8 @@ function App() { {/* Rendered last so its no-drag region paints over the sidebar drag region */} setIsSearchOpen(true)} />
diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index bb972eff..7f370355 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -1419,6 +1419,18 @@ export interface BgTasksViewProps { * "Edit with Copilot" button in the detail-view sidebar footer. */ onEditWithCopilot?: (slug: string) => void + /** + * If provided, the view opens with this task already selected. Updates to + * this prop sync into internal state so the sidebar can swap which task is + * focused without remounting the view. + */ + initialSlug?: string | null + /** + * Bump this counter to force a re-focus on `initialSlug` even when the + * slug value itself didn't change (e.g. user clicks the same task in the + * sidebar twice after navigating away inside the view). + */ + slugVersion?: number } function formatLastRanLabel(iso: string | null | undefined): string { @@ -1426,9 +1438,12 @@ function formatLastRanLabel(iso: string | null | undefined): string { return formatRelativeTime(iso) || 'Never' } -export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) { +export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) { const [items, setItems] = useState([]) - const [selectedSlug, setSelectedSlug] = useState(null) + const [selectedSlug, setSelectedSlug] = useState(initialSlug ?? null) + useEffect(() => { + setSelectedSlug(initialSlug ?? null) + }, [initialSlug, slugVersion]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showNewDialog, setShowNewDialog] = useState(false) diff --git a/apps/x/apps/renderer/src/components/chat-history-view.tsx b/apps/x/apps/renderer/src/components/chat-history-view.tsx new file mode 100644 index 00000000..44ab9f4c --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-history-view.tsx @@ -0,0 +1,177 @@ +import { useCallback, useMemo, useState } from 'react' +import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { formatRelativeTime } from '@/lib/relative-time' + +type Run = { + id: string + title?: string + createdAt: string + agentId: string +} + +type ChatHistoryViewProps = { + runs: Run[] + currentRunId?: string | null + processingRunIds?: Set + onSelectRun: (runId: string) => void + onOpenInNewTab?: (runId: string) => void + onDeleteRun: (runId: string) => Promise | void + onNewChat?: () => void + onOpenSearch?: () => void +} + +export function ChatHistoryView({ + runs, + currentRunId, + processingRunIds, + onSelectRun, + onOpenInNewTab, + onDeleteRun, + onNewChat, + onOpenSearch, +}: ChatHistoryViewProps) { + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + const sortedRuns = useMemo(() => { + return [...runs].sort((a, b) => { + const at = new Date(a.createdAt).getTime() + const bt = new Date(b.createdAt).getTime() + return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at) + }) + }, [runs]) + + const handleConfirmDelete = useCallback(async () => { + if (!pendingDeleteId) return + const id = pendingDeleteId + setPendingDeleteId(null) + await onDeleteRun(id) + }, [pendingDeleteId, onDeleteRun]) + + return ( +
+
+

Chat history

+
+ {onOpenSearch && ( + + )} + {onNewChat && ( + + )} +
+
+ +
+
+
+
Title
+
Created
+
+ + {sortedRuns.length === 0 ? ( +
No chats yet.
+ ) : ( + sortedRuns.map((run) => { + const isActive = currentRunId === run.id + const isProcessing = processingRunIds?.has(run.id) + return ( + + + + + + {onOpenInNewTab && ( + <> + onOpenInNewTab(run.id)}> + + Open in new tab + + + + )} + {!isProcessing && ( + setPendingDeleteId(run.id)} + > + + Delete + + )} + + + ) + }) + )} +
+
+ + { if (!open) setPendingDeleteId(null) }}> + + + Delete chat + + Are you sure you want to delete this chat? + + + + + + + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index ccd96805..97386508 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -274,9 +274,21 @@ function ChatInputInner({ const handleSetWorkDir = useCallback(async () => { 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', { title: 'Choose work directory', - defaultPath: workDir ?? undefined, + defaultPath, }) if (!chosen) return await window.ipc.invoke('workspace:writeFile', { diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 674547e0..49566d11 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -817,12 +817,28 @@ function clearLoadingFlag(state: SectionState | null): SectionState { return { ...state, loadingPage: false } } -export function EmailView() { +export type EmailViewProps = { + /** If provided, the view opens with this thread already expanded. */ + initialThreadId?: string | null + /** Bump to re-focus on the same threadId after navigating away inside the view. */ + threadIdVersion?: number +} + +export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) { const [important, setImportant] = useState(() => clearLoadingFlag(persistedImportant)) const [other, setOther] = useState(() => clearLoadingFlag(persistedOther)) const hadPersistedDataOnMount = useRef(persistedImportant !== null) - const [selectedThreadId, setSelectedThreadId] = useState(null) - const [openedThreadIds, setOpenedThreadIds] = useState([]) + const [selectedThreadId, setSelectedThreadId] = useState(initialThreadId ?? null) + const [openedThreadIds, setOpenedThreadIds] = useState(initialThreadId ? [initialThreadId] : []) + useEffect(() => { + setSelectedThreadId(initialThreadId ?? null) + if (initialThreadId) { + setOpenedThreadIds((prev) => { + const without = prev.filter((id) => id !== initialThreadId) + return [...without, initialThreadId].slice(-MAX_KEPT_OPEN) + }) + } + }, [initialThreadId, threadIdVersion]) const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current) const [error, setError] = useState(null) const [query, setQuery] = useState('') diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 994ab73e..8343af28 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -1,33 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' const MAX_SIZE_BYTES = 5 * 1024 * 1024 -const CACHE_MAX_ENTRIES = 20 - -type CacheEntry = { html: string; mtimeMs: number; size: number } -const htmlCache = new Map() - -function getCached(path: string, mtimeMs: number, size: number): string | null { - const entry = htmlCache.get(path) - if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null - // Refresh LRU position - htmlCache.delete(path) - htmlCache.set(path, entry) - return entry.html -} - -function setCached(path: string, html: string, mtimeMs: number, size: number) { - htmlCache.set(path, { html, mtimeMs, size }) - while (htmlCache.size > CACHE_MAX_ENTRIES) { - const oldest = htmlCache.keys().next().value - if (oldest === undefined) break - htmlCache.delete(oldest) - } -} type ViewerState = | { kind: 'loading' } - | { kind: 'loaded'; html: string } + | { kind: 'loaded' } | { kind: 'empty' } | { kind: 'tooLarge'; sizeMB: number } | { kind: 'error'; message: string } @@ -36,9 +14,15 @@ interface HtmlFileViewerProps { path: string } +function toAppWorkspaceUrl(path: string): string { + const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg)) + return `app://workspace/${segments.join('/')}` +} + export function HtmlFileViewer({ path }: HtmlFileViewerProps) { const [state, setState] = useState({ kind: 'loading' }) const [iframeLoaded, setIframeLoaded] = useState(false) + const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path]) useEffect(() => { let cancelled = false @@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) { setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) }) return } - const cachedHtml = getCached(path, stat.mtimeMs, stat.size) - if (cachedHtml !== null) { - setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml }) - return - } - const result = await window.ipc.invoke('workspace:readFile', { path }) - if (cancelled) return - setCached(path, result.data, stat.mtimeMs, stat.size) - if (!result.data || result.data.trim() === '') { + if (stat.size === 0) { setState({ kind: 'empty' }) return } - setState({ kind: 'loaded', html: result.data }) + setState({ kind: 'loaded' }) } catch (err) { if (cancelled) return const message = err instanceof Error ? err.message : String(err) @@ -124,20 +100,16 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) { ) } - // We use `srcDoc` here (not `src=app://workspace/`) so the iframe - // gets a null origin with no base URL. Trade-off: relative assets inside - // the file — ``, ``, - // `