From 193c2a91317b3113de86673bc087f5e3eb96d62e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 15:46:53 +0530 Subject: [PATCH] move tasks, meetings, and email into the sidebar; add chat history Surface background tasks, upcoming meetings (with a live recording indicator), and important unread email directly in the sidebar; add a chat history page with chat icons and a home action row. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/renderer/src/App.tsx | 307 ++++-- .../renderer/src/components/bg-tasks-view.tsx | 19 +- .../src/components/chat-history-view.tsx | 177 ++++ .../renderer/src/components/email-view.tsx | 22 +- .../src/components/knowledge-view.tsx | 2 +- .../src/components/sidebar-content.tsx | 917 +++++++++++++----- 6 files changed, 1128 insertions(+), 316 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-history-view.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b641575c..e29d3ea9 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, SearchIcon, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -27,6 +27,7 @@ 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 { @@ -189,6 +190,7 @@ 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) => @@ -324,6 +326,7 @@ 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) => { @@ -576,6 +579,7 @@ type ViewState = | { 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 @@ -632,6 +636,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'knowledge-view': return { type: 'knowledge-view' } + case 'chat-history': + return { type: 'chat-history' } default: return null } @@ -640,12 +646,8 @@ 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 ( @@ -661,28 +663,6 @@ function FixedSidebarToggle({ > - {onNewChat && ( - - )} - {onOpenSearch && ( - - )} ) } @@ -770,6 +750,9 @@ function App() { 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 @@ -1125,7 +1108,8 @@ function App() { 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 (isKnowledgeViewTabPath(tab.path)) return 'Notes' + 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 @@ -1414,6 +1398,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) { @@ -1759,6 +1750,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 { @@ -2840,7 +2862,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(path) return } @@ -2849,7 +2871,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2868,14 +2890,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) return } if (isLiveNotesTabPath(tab.path)) { @@ -2887,6 +2909,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) return } @@ -2899,6 +2922,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsBgTasksOpen(true) return } @@ -2912,6 +2936,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) return } if (isEmailTabPath(tab.path)) { @@ -2923,6 +2948,7 @@ function App() { setIsBgTasksOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsEmailOpen(true) return } @@ -2935,6 +2961,7 @@ function App() { setIsBgTasksOpen(false) setIsEmailOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsWorkspaceOpen(true) return } @@ -2947,18 +2974,32 @@ function App() { 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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(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) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(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) @@ -2981,7 +3022,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2995,12 +3036,12 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (isMeetingsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3011,6 +3052,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3020,6 +3062,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) } else if (isBgTasksTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3031,6 +3074,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) } else if (isEmailTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3040,6 +3084,7 @@ function App() { setIsBgTasksOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsEmailOpen(true) } else if (isWorkspaceTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3050,6 +3095,7 @@ function App() { setIsBgTasksOpen(false) setIsEmailOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsWorkspaceOpen(true) } else if (isKnowledgeViewTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3060,11 +3106,23 @@ function App() { 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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(newActiveTab.path) } } @@ -3095,7 +3153,7 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3112,8 +3170,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen]) + 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(() => { @@ -3245,7 +3303,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 || isWorkspaceOpen || isKnowledgeViewOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3261,19 +3319,19 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, 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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(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); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) } else if (expandedFrom.meetings) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) @@ -3305,7 +3363,7 @@ function App() { } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3321,10 +3379,11 @@ function App() { 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, isWorkspaceOpen, isKnowledgeViewOpen, workspaceInitialPath, 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] @@ -3447,6 +3506,17 @@ function App() { 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) @@ -3469,7 +3539,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3503,7 +3573,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(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. @@ -3518,7 +3588,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3531,7 +3601,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3544,7 +3614,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) ensureSuggestedTopicsFileTab() return case 'meetings': @@ -3561,6 +3631,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) ensureMeetingsFileTab() return case 'live-notes': @@ -3576,6 +3647,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setIsLiveNotesOpen(true) ensureLiveNotesFileTab() return @@ -3593,6 +3665,7 @@ function App() { setIsEmailOpen(true) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) ensureEmailFileTab() return case 'workspace': @@ -3609,6 +3682,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(true) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) setWorkspaceInitialPath(view.path ?? null) ensureWorkspaceFileTab() return @@ -3626,8 +3700,26 @@ function App() { 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) @@ -3636,7 +3728,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3644,7 +3736,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3966,7 +4058,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !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') { @@ -4051,11 +4143,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 || isWorkspaceOpen || isKnowledgeViewOpen) && 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 || isWorkspaceOpen || isKnowledgeViewOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen @@ -4072,6 +4164,8 @@ function App() { ? WORKSPACE_TAB_PATH : isKnowledgeViewOpen ? KNOWLEDGE_VIEW_TAB_PATH + : isChatHistoryOpen + ? CHAT_HISTORY_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -4126,7 +4220,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, 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') { @@ -4151,7 +4245,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4277,28 +4371,28 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !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 && !isWorkspaceOpen && !isKnowledgeViewOpen && !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 && !selectedBackgroundTask) { + 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 && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4471,12 +4565,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 @@ -4504,6 +4600,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 @@ -4913,7 +5010,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || 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(() => { @@ -4930,7 +5027,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -4953,7 +5050,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4964,7 +5061,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 || isWorkspaceOpen || isKnowledgeViewOpen || 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 @@ -4988,14 +5085,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 || isWorkspaceOpen || isKnowledgeViewOpen || 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 || isWorkspaceOpen || isKnowledgeViewOpen || 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 { @@ -5010,20 +5107,30 @@ 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} - isSearchOpen={isSearchOpen} - isBrowserOpen={isBrowserOpen} - onToggleBrowser={handleToggleBrowser} - isSuggestedTopicsOpen={isSuggestedTopicsOpen} - onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} - isMeetingsOpen={isMeetingsOpen} onOpenMeetings={openMeetingsView} - 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() + }} + onOpenHome={navigateToFullScreenChat} + onNewChat={handleNewChatTab} + onOpenSearch={() => setIsSearchOpen(true)} + onToggleBrowser={handleToggleBrowser} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && 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 || isWorkspaceOpen || isKnowledgeViewOpen || (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 && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( + )} + {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/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/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index bbd16fc9..15f9f49b 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -142,7 +142,7 @@ export function KnowledgeView({ return (
-

Knowledge

+

Notes

)} - {onOpenMeetings && ( + {onNewChat && ( )} - {onOpenBgTasks && ( + {onToggleBrowser && ( + )} + {onOpenSearch && ( + )}
+ setConnectorsOpen(true)} + /> + setConnectorsOpen(true)} + recordingState={meetingRecordingState ?? 'idle'} + recordingSource={recordingMeetingSource ?? null} + onToggleRecording={onToggleMeetingRecording} + /> + {/* Billing / upgrade CTA or Log in CTA */} @@ -638,43 +641,6 @@ export function SidebarContentPanel({
)} - {/* Secondary quick actions (above bottom divider) */} - {(onToggleBrowser || onOpenSuggestedTopics) && ( -
-
- {onToggleBrowser && ( - - )} - {onOpenSuggestedTopics && ( - - )} -
-
- )} {/* Bottom actions */}
@@ -1040,7 +1006,7 @@ function KnowledgeSection({
- Knowledge + Notes
@@ -1056,9 +1022,24 @@ function KnowledgeSection({ ))} - actions.openKnowledgeView()}> - - View all + + recentNotes.length === 0 + ? actions.createNote() + : actions.openKnowledgeView() + } + > + {recentNotes.length === 0 ? ( + <> + + New note + + ) : ( + <> + + View all + + )} @@ -1112,8 +1093,17 @@ export function WorkspaceSection({ ))} actions.openWorkspaceAt()}> - - View all + {recentWorkspaces.length === 0 ? ( + <> + + New workspace + + ) : ( + <> + + View all + + )} @@ -1123,133 +1113,620 @@ export function WorkspaceSection({ } -// Get status indicator color -function getStatusColor(status?: string, enabled?: boolean): string { - // Disabled agents always show gray - if (enabled === false) { - return "bg-gray-400" +type UpcomingMeeting = { + id: string + summary: string + start: Date + isAllDay: boolean + location: string | null + htmlLink: string | null + conferenceLink: string | null + source: string + rawStart: { dateTime?: string; date?: string } | undefined + rawEnd: { dateTime?: string; date?: string } | undefined +} + +type RawCalendarEvent = { + id?: string + summary?: string + start?: { dateTime?: string; date?: string } + end?: { dateTime?: string; date?: string } + location?: string + htmlLink?: string + status?: string + attendees?: Array<{ self?: boolean; responseStatus?: string }> +} + +function parseAllDayDate(s: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s) + if (!m) return null + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) +} + +function normalizeUpcomingMeeting(raw: RawCalendarEvent, sourcePath: string): UpcomingMeeting | null { + if (raw.status === 'cancelled') return null + const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined' + if (declined) return null + const allDayStart = raw.start?.date + const timedStart = raw.start?.dateTime + const isAllDay = !timedStart && Boolean(allDayStart) + let start: Date | null = null + let end: Date | null = null + if (timedStart) { + start = new Date(timedStart) + end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null + } else if (allDayStart) { + start = parseAllDayDate(allDayStart) + end = raw.end?.date ? parseAllDayDate(raw.end.date) : null } - switch (status) { - case "running": - return "bg-blue-500" - case "finished": - return "bg-green-500" - case "failed": - return "bg-red-500" - case "triggered": - return "bg-gray-400" - case "scheduled": - default: - return "bg-yellow-500" + if (!start || Number.isNaN(start.getTime())) return null + const now = new Date() + const effectiveEnd = end ?? (isAllDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : start) + if (effectiveEnd <= now) return null + const conferenceLink = extractConferenceLink(raw as unknown as Record) ?? null + return { + id: raw.id ?? sourcePath, + summary: raw.summary?.trim() || '(No title)', + start, + isAllDay, + location: raw.location?.trim() || null, + htmlLink: raw.htmlLink ?? null, + conferenceLink, + source: sourcePath, + rawStart: raw.start, + rawEnd: raw.end, } } +function triggerMeetingCapture(event: UpcomingMeeting, openConference: boolean) { + window.__pendingCalendarEvent = { + summary: event.summary, + start: event.rawStart, + end: event.rawEnd, + location: event.location ?? undefined, + htmlLink: event.htmlLink ?? undefined, + conferenceLink: event.conferenceLink ?? undefined, + source: event.source, + } + if (openConference && event.conferenceLink) { + window.open(event.conferenceLink, '_blank') + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) +} + +function isSameLocalDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() +} + +function formatMeetingTime(event: UpcomingMeeting): string { + if (event.isAllDay) return 'All day' + const now = new Date() + const tomorrow = new Date(now) + tomorrow.setDate(tomorrow.getDate() + 1) + const time = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (isSameLocalDay(event.start, now)) return time + if (isSameLocalDay(event.start, tomorrow)) return `Tmrw ${time}` + return event.start.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) +} + +type SidebarEmailThread = { + threadId: string + subject: string + from: string + date: string +} + +function formatEmailFrom(from: string): string { + const match = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from) + if (match) return match[1].trim() + return from +} + +function formatEmailTime(value: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.round(diffMs / 60000) + if (diffMin < 1) return 'now' + if (diffMin < 60) return `${diffMin}m` + const sameDay = date.toDateString() === now.toDateString() + if (sameDay) return `${Math.round(diffMin / 60)}h` + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Yest' + if (diffMs < 7 * 24 * 60 * 60 * 1000) return date.toLocaleDateString([], { weekday: 'short' }) + if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: '2-digit' }) +} + +function EmailSidebarSection({ + onOpenEmailView, + onOpenConnectors, +}: { + onOpenEmailView?: (threadId?: string) => void + onOpenConnectors?: () => void +}) { + const [threads, setThreads] = useState([]) + const [connected, setConnected] = useState(null) + + const refreshConnected = useCallback(async () => { + try { + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setConnected(result.isConnected) + } catch { + setConnected(false) + } + }, []) + + useEffect(() => { + void refreshConnected() + const cleanup = window.ipc.on('oauth:didConnect', () => { void refreshConnected() }) + return cleanup + }, [refreshConnected]) + + const load = useCallback(async () => { + try { + const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 }) + const unread = result.threads + .filter((t) => t.unread === true) + .slice(0, 3) + .map((t) => ({ + threadId: t.threadId, + subject: t.subject ?? '(No subject)', + from: t.from ?? '', + date: t.date ?? '', + })) + setThreads(unread) + } catch (err) { + console.error('Failed to load important emails:', err) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + let timeout: ReturnType | null = null + const scheduleReload = () => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { timeout = null; void load() }, 500) + } + const matches = (p: string | undefined) => + typeof p === 'string' && (p === 'gmail_sync' || p.startsWith('gmail_sync/')) + const cleanup = window.ipc.on('workspace:didChange', (event) => { + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + if (matches(event.path)) scheduleReload() + break + case 'moved': + if (matches(event.from) || matches(event.to)) scheduleReload() + break + case 'bulkChanged': + if (!event.paths || event.paths.some(matches)) scheduleReload() + break + } + }) + return () => { + if (timeout) clearTimeout(timeout) + cleanup() + } + }, [load]) + + return ( + +
+ Email +
+ + + {threads.map((t) => ( + + onOpenEmailView?.(t.threadId)} className="gap-2"> + + + {formatEmailFrom(t.from)} + · {t.subject} + + {t.date && ( + + {formatEmailTime(t.date)} + + )} + + + ))} + {connected === false && threads.length === 0 ? ( + onOpenConnectors && ( + + + + Connect Email + + + ) + ) : ( + onOpenEmailView && ( + + onOpenEmailView()}> + + View all + + + ) + )} + + +
+ ) +} + +function MeetingsSidebarSection({ + onOpenMeetingsView, + onOpenConnectors, + recordingState, + recordingSource, + onToggleRecording, +}: { + onOpenMeetingsView?: () => void + onOpenConnectors?: () => void + recordingState: 'idle' | 'connecting' | 'recording' | 'stopping' + recordingSource: string | null + onToggleRecording?: () => void +}) { + const [meetings, setMeetings] = useState([]) + const [calendarConnected, setCalendarConnected] = useState(null) + + const refreshCalendarConnected = useCallback(async () => { + try { + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setCalendarConnected(result.isConnected) + } catch { + setCalendarConnected(false) + } + }, []) + + useEffect(() => { + void refreshCalendarConnected() + const cleanup = window.ipc.on('oauth:didConnect', () => { void refreshCalendarConnected() }) + return cleanup + }, [refreshCalendarConnected]) + + const load = useCallback(async () => { + try { + const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' }) + if (!exists.exists) { + setMeetings([]) + return + } + const entries = await window.ipc.invoke('workspace:readdir', { + path: 'calendar_sync', + opts: { recursive: false, includeHidden: false, includeStats: false }, + }) + const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json')) + const settled = await Promise.allSettled( + jsonEntries.map(async (entry): Promise => { + const result = await window.ipc.invoke('workspace:readFile', { + path: entry.path, + encoding: 'utf8', + }) + const raw = JSON.parse(result.data) as RawCalendarEvent + return normalizeUpcomingMeeting(raw, entry.path) + }), + ) + const collected: UpcomingMeeting[] = [] + for (const r of settled) { + if (r.status === 'fulfilled' && r.value) collected.push(r.value) + } + collected.sort((a, b) => { + if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1 + return a.start.getTime() - b.start.getTime() + }) + setMeetings(collected.slice(0, 3)) + } catch (err) { + console.error('Failed to load upcoming meetings:', err) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + let timeout: ReturnType | null = null + const scheduleReload = () => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { timeout = null; void load() }, 250) + } + const matches = (p: string | undefined) => + typeof p === 'string' && (p === 'calendar_sync' || p.startsWith('calendar_sync/')) + const cleanup = window.ipc.on('workspace:didChange', (event) => { + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + if (matches(event.path)) scheduleReload() + break + case 'moved': + if (matches(event.from) || matches(event.to)) scheduleReload() + break + case 'bulkChanged': + if (!event.paths || event.paths.some(matches)) scheduleReload() + break + } + }) + const tick = setInterval(() => void load(), 60 * 60 * 1000) + return () => { + if (timeout) clearTimeout(timeout) + clearInterval(tick) + cleanup() + } + }, [load]) + + return ( + +
+ Meetings +
+ + + {meetings.map((m) => { + const hasConference = Boolean(m.conferenceLink) + const isThisRecording = recordingSource === m.source && (recordingState === 'recording' || recordingState === 'connecting' || recordingState === 'stopping') + const isBusy = isThisRecording && (recordingState === 'connecting' || recordingState === 'stopping') + return ( + + + + {m.summary} + + {isThisRecording ? null : formatMeetingTime(m)} + + + {isThisRecording ? ( +
+ + + + + + + + + + {recordingState === 'connecting' ? 'Starting…' : recordingState === 'stopping' ? 'Stopping…' : 'Stop recording'} + + +
+ ) : ( +
+ + + + + Take notes + + {hasConference && ( + + + + + Join & take notes + + )} +
+ )} +
+ ) + })} + {calendarConnected === false && meetings.length === 0 ? ( + onOpenConnectors && ( + + + + Connect Calendar + + + ) + ) : ( + onOpenMeetingsView && ( + + + + View all + + + ) + )} +
+
+
+ ) +} + +function TasksSidebarSection({ + tasks, + onOpenTask, + onOpenTasksView, +}: { + tasks: TaskSummary[] + onOpenTask?: (slug: string) => void + onOpenTasksView?: () => void +}) { + const recentTasks = React.useMemo(() => { + const toTime = (s?: string | null): number => { + if (!s) return 0 + const t = new Date(s).getTime() + return Number.isNaN(t) ? 0 : t + } + const activity = (t: TaskSummary): number => + Math.max(toTime(t.lastRunAt), toTime(t.lastAttemptAt), toTime(t.createdAt)) + return [...tasks] + .sort((a, b) => activity(b) - activity(a)) + .slice(0, 3) + }, [tasks]) + + return ( + +
+ Tasks +
+ + + {recentTasks.map((task) => ( + + onOpenTask?.(task.slug)} + className="gap-2" + > + + + {task.name} + + + + ))} + {onOpenTasksView && ( + + + {recentTasks.length === 0 ? ( + <> + + New Task + + ) : ( + <> + + View all + + )} + + + )} + + +
+ ) +} + // Tasks Section function TasksSection({ runs, currentRunId, processingRunIds, actions, - backgroundTasks = [], - selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null processingRunIds?: Set actions?: TasksActions - backgroundTasks?: BackgroundTaskItem[] - selectedBackgroundTask?: string | null }) { const [pendingDeleteRunId, setPendingDeleteRunId] = useState(null) return ( - {/* Background Tasks Section */} - {backgroundTasks.length > 0 && ( - <> -
- Background Tasks -
- - {backgroundTasks.map((task) => ( - +
+ Chat history +
+ + {runs.slice(0, 3).map((run) => ( + + + actions?.onSelectBackgroundTask?.(task.name)} - className="gap-2" + isActive={currentRunId === run.id} + onClick={(e) => { + if (e.metaKey && actions?.onOpenInNewTab) { + actions.onOpenInNewTab(run.id) + } else { + actions?.onSelectRun(run.id) + } + }} > -
- - +
+ + {run.title || '(Untitled chat)'} + {run.createdAt ? ( + + {formatRunTime(run.createdAt)} + + ) : null}
- - {task.name} - - ))} - - - )} - {runs.length > 0 && ( - <> -
- Chat history -
- - {runs.map((run) => ( - - - - { - if (e.metaKey && actions?.onOpenInNewTab) { - actions.onOpenInNewTab(run.id) - } else { - actions?.onSelectRun(run.id) - } - }} - > -
- {run.title || '(Untitled chat)'} - {run.createdAt ? ( - - {formatRunTime(run.createdAt)} - - ) : null} -
-
-
-
- - {actions?.onOpenInNewTab && ( - actions.onOpenInNewTab!(run.id)}> - - Open in new tab - - )} - {!processingRunIds?.has(run.id) && ( - <> - {actions?.onOpenInNewTab && } - setPendingDeleteRunId(run.id)} - > - - Delete - - - )} - -
- ))} -
- - )} + + + {actions?.onOpenInNewTab && ( + actions.onOpenInNewTab!(run.id)}> + + Open in new tab + + )} + {!processingRunIds?.has(run.id) && ( + <> + {actions?.onOpenInNewTab && } + setPendingDeleteRunId(run.id)} + > + + Delete + + + )} + + + ))} + {runs.length > 0 && actions?.onOpenChatHistoryView && ( + + actions.onOpenChatHistoryView?.()}> + + View all + + + )} + {/* Delete confirmation dialog */}