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 (
-
+
{/* Sidebar toggle */}
+ {onNewChat && (
+
+ )}
+ {onOpenSearch && (
+
+ )}
)
}
@@ -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 && (
)}
- {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
+ {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
)}
- {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
+ {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && (
) : 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 && (
+
+ )}
+
+
+
+
+
+
+
+ {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
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+
+
+
+ )
+}
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 — ``, `
`,
- // `