From fbd0791d0c13a94e928ce99417794221086d150f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 15:47:20 +0530 Subject: [PATCH] implement the new navigation design Rebuild the sidebar, home, and chat surfaces per the navigation design: Recents (capped at 5), single clickable previews, section separators, and the chat page help items and discovery carousel. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/x/apps/renderer/src/App.tsx | 423 +++--- .../src/components/chat-empty-state.tsx | 106 ++ .../renderer/src/components/chat-header.tsx | 114 ++ .../renderer/src/components/chat-sidebar.tsx | 71 +- .../renderer/src/components/home-view.tsx | 503 +++++++ .../src/components/sidebar-content.tsx | 1302 ++++++----------- 6 files changed, 1467 insertions(+), 1052 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-empty-state.tsx create mode 100644 apps/x/apps/renderer/src/components/chat-header.tsx create mode 100644 apps/x/apps/renderer/src/components/home-view.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1d0d9076..deeb045a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,10 +5,12 @@ 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, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; +import { ChatHeader } from './components/chat-header'; +import { ChatEmptyState } from './components/chat-empty-state'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; @@ -28,12 +30,12 @@ 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 { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, ConversationContent, - ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { @@ -55,7 +57,6 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, @@ -191,6 +192,7 @@ 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 HOME_TAB_PATH = '__rowboat_home__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -327,6 +329,7 @@ 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 isHomeTabPath = (path: string) => path === HOME_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -580,6 +583,7 @@ type ViewState = | { type: 'workspace'; path?: string } | { type: 'knowledge-view' } | { type: 'chat-history' } + | { type: 'home' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -638,6 +642,8 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'knowledge-view' } case 'chat-history': return { type: 'chat-history' } + case 'home': + return { type: 'home' } default: return null } @@ -751,6 +757,7 @@ function App() { const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) + const [isHomeOpen, setIsHomeOpen] = useState(false) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) const [expandedFrom, setExpandedFrom] = useState<{ @@ -1009,13 +1016,13 @@ function App() { // Chat tab state const [chatTabs, setChatTabs] = useState([{ id: 'default-chat-tab', runId: null }]) + const chatTabsRef = useRef(chatTabs) + chatTabsRef.current = chatTabs const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab') const [chatViewStateByTab, setChatViewStateByTab] = useState>({ 'default-chat-tab': createEmptyChatTabViewState(), }) const chatViewStateByTabRef = useRef(chatViewStateByTab) - const chatTabIdCounterRef = useRef(0) - const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map()) const selectedModelByTabRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) @@ -1110,6 +1117,7 @@ function App() { if (isWorkspaceTabPath(tab.path)) return 'Workspace' if (isKnowledgeViewTabPath(tab.path)) return 'Notes' if (isChatHistoryTabPath(tab.path)) return 'Chat history' + if (isHomeTabPath(tab.path)) return 'Home' 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 @@ -1757,6 +1765,7 @@ function App() { createdAt: string lastAttemptAt?: string lastRunAt?: string + lastRunError?: string }>>([]) const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState(null) const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0) @@ -1771,6 +1780,7 @@ function App() { createdAt: it.createdAt, lastAttemptAt: it.lastAttemptAt, lastRunAt: it.lastRunAt, + lastRunError: it.lastRunError, }))) } catch (err) { console.error('Failed to load bg-task summaries:', err) @@ -2699,25 +2709,6 @@ function App() { return true }, []) - const openChatInNewTab = useCallback((targetRunId: string) => { - cancelRecordingIfActive() - const existingTab = chatTabs.find(t => t.runId === targetRunId) - if (existingTab) { - // Cancel stale in-flight loads from previously focused tabs. - loadRunRequestIdRef.current += 1 - setActiveChatTabId(existingTab.id) - const restored = restoreChatTabState(existingTab.id, existingTab.runId) - if (processingRunIdsRef.current.has(targetRunId) || !restored) { - loadRun(targetRunId) - } - return - } - const id = newChatTabId() - setChatTabs(prev => [...prev, { id, runId: targetRunId }]) - setActiveChatTabId(id) - loadRun(targetRunId) - }, [chatTabs, loadRun, restoreChatTabState, cancelRecordingIfActive]) - const switchChatTab = useCallback((tabId: string) => { const tab = chatTabs.find(t => t.id === tabId) if (!tab) return @@ -2862,7 +2853,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(path) return } @@ -2871,7 +2862,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2890,14 +2881,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) return } if (isLiveNotesTabPath(tab.path)) { @@ -2910,6 +2901,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsLiveNotesOpen(true) return } @@ -2923,6 +2915,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsBgTasksOpen(true) return } @@ -2937,6 +2930,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) return } if (isEmailTabPath(tab.path)) { @@ -2949,6 +2943,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsEmailOpen(true) return } @@ -2962,6 +2957,7 @@ function App() { setIsEmailOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsWorkspaceOpen(true) return } @@ -2975,6 +2971,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsKnowledgeViewOpen(true) return } @@ -2988,18 +2985,26 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) - setIsChatHistoryOpen(true) + setIsChatHistoryOpen(true); setIsHomeOpen(false) + return + } + if (isHomeTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsHomeOpen(true) return } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(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) && !isChatHistoryTabPath(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) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -3022,7 +3027,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -3036,12 +3041,12 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) } else if (isMeetingsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3053,6 +3058,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3063,6 +3069,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsLiveNotesOpen(true) } else if (isBgTasksTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3075,6 +3082,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) } else if (isEmailTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) @@ -3085,6 +3093,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsEmailOpen(true) } else if (isWorkspaceTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3096,6 +3105,7 @@ function App() { setIsEmailOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsWorkspaceOpen(true) } else if (isKnowledgeViewTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3107,6 +3117,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsKnowledgeViewOpen(true) } else if (isChatHistoryTabPath(newActiveTab.path)) { setSelectedPath(null) @@ -3118,11 +3129,17 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) - setIsChatHistoryOpen(true) - } else { + setIsChatHistoryOpen(true); setIsHomeOpen(false) + } else if (isHomeTabPath(newActiveTab.path)) { + setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsHomeOpen(true) + } else { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(newActiveTab.path) } } @@ -3138,22 +3155,13 @@ function App() { }, [activeFileTabId, fileTabs, removeEditorCacheForPath]) const handleNewChatTab = useCallback(() => { - // If there's already an empty "New chat" tab, switch to it - const emptyTab = chatTabs.find(t => !t.runId) - if (emptyTab) { - if (emptyTab.id !== activeChatTabId) { - setActiveChatTabId(emptyTab.id) - } - } else { - // Create a new tab - const id = newChatTabId() - setChatTabs(prev => [...prev, { id, runId: null }]) - setActiveChatTabId(id) - } + // Single-chat model: reset the one conversation in place instead of + // opening a new tab. + setChatTabs([{ id: activeChatTabIdRef.current, runId: null }]) dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3170,23 +3178,14 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - 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]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + }, [dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen]) - // Sidebar variant: create/switch chat tab without leaving file/graph context. + // Sidebar variant: reset the chat in place without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { - const emptyTab = chatTabs.find(t => !t.runId) - if (emptyTab) { - if (emptyTab.id !== activeChatTabId) { - setActiveChatTabId(emptyTab.id) - } - } else { - const id = newChatTabId() - setChatTabs(prev => [...prev, { id, runId: null }]) - setActiveChatTabId(id) - } + setChatTabs([{ id: activeChatTabIdRef.current, runId: null }]) handleNewChat() - }, [chatTabs, activeChatTabId, handleNewChat]) + }, [handleNewChat]) // Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab, // queues the message; the pending-submit effect (below) flushes it once state has settled @@ -3303,7 +3302,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 || isChatHistoryOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, @@ -3319,7 +3318,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { @@ -3327,11 +3326,11 @@ function App() { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) } else if (expandedFrom.meetings) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) @@ -3363,7 +3362,7 @@ function App() { } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3380,10 +3379,11 @@ function App() { if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } if (isKnowledgeViewOpen) return { type: 'knowledge-view' } if (isChatHistoryOpen) return { type: 'chat-history' } + if (isHomeOpen) return { type: 'home' } 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, isChatHistoryOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3517,7 +3517,18 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) - const openEmailView = useCallback(() => { + const ensureHomeFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isHomeTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: HOME_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + + const openEmailView = useCallback((threadId?: string) => { setSelectedPath(null) setIsGraphOpen(false) setIsBrowserOpen(false) @@ -3527,10 +3538,16 @@ function App() { setIsBgTasksOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) + setIsHomeOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) setIsEmailOpen(true) + if (threadId) { + setEmailInitialThreadId(threadId) + setEmailThreadIdVersion((v) => v + 1) + } ensureEmailFileTab() }, [ensureEmailFileTab]) @@ -3539,7 +3556,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3558,6 +3575,8 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) + setIsChatHistoryOpen(false) + setIsHomeOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3573,7 +3592,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(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. @@ -3588,7 +3607,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3601,7 +3620,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3614,7 +3633,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) ensureSuggestedTopicsFileTab() return case 'meetings': @@ -3632,6 +3651,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) ensureMeetingsFileTab() return case 'live-notes': @@ -3648,6 +3668,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setIsLiveNotesOpen(true) ensureLiveNotesFileTab() return @@ -3666,6 +3687,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) ensureEmailFileTab() return case 'workspace': @@ -3683,6 +3705,7 @@ function App() { setIsWorkspaceOpen(true) setIsKnowledgeViewOpen(false) setIsChatHistoryOpen(false) + setIsHomeOpen(false) setWorkspaceInitialPath(view.path ?? null) ensureWorkspaceFileTab() return @@ -3701,6 +3724,7 @@ function App() { setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) setIsChatHistoryOpen(false) + setIsHomeOpen(false) ensureKnowledgeViewFileTab() return case 'chat-history': @@ -3717,9 +3741,27 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(false) - setIsChatHistoryOpen(true) + setIsChatHistoryOpen(true); setIsHomeOpen(false) ensureChatHistoryFileTab() return + case 'home': + 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(false) + setIsHomeOpen(true) + ensureHomeFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3728,15 +3770,27 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) if (view.runId) { - await loadRun(view.runId) + const targetRunId = view.runId + // Bind the loaded run to a chat tab so its title (derived from + // tab.runId) updates. Reuse an existing tab for this run if one is + // open, otherwise rebind the active tab. + const existingTab = chatTabsRef.current.find((tab) => tab.runId === targetRunId) + if (existingTab) { + setActiveChatTabId(existingTab.id) + } else { + setChatTabs((prev) => prev.map((tab) => ( + tab.id === activeChatTabIdRef.current ? { ...tab, runId: targetRunId } : tab + ))) + } + await loadRun(targetRunId) } else { handleNewChat() } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -4058,7 +4112,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -4143,11 +4197,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 || isChatHistoryOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen @@ -4166,6 +4220,8 @@ function App() { ? KNOWLEDGE_VIEW_TAB_PATH : isChatHistoryOpen ? CHAT_HISTORY_TAB_PATH + : isHomeOpen + ? HOME_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -5006,11 +5062,10 @@ function App() { if (tabId === activeChatTabId) return activeChatTabState return chatViewStateByTab[tabId] ?? emptyChatTabState }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState]) - const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -5027,7 +5082,7 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> @@ -5040,97 +5095,31 @@ function App() { > { - cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) { - setIsChatSidebarOpen(true) - } - - // If already open in a chat tab, switch to it - const existingTab = chatTabs.find(t => t.runId === runIdToLoad) - if (existingTab) { - switchChatTab(existingTab.id) - 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 || isChatHistoryOpen || isBrowserOpen) { - setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) - loadRun(runIdToLoad) - return - } - - // Outside two-pane mode, navigate to chat. - setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) - void navigateToView({ type: 'chat', runId: runIdToLoad }) - }, - onOpenInNewTab: (targetRunId) => { - openChatInNewTab(targetRunId) - }, - onDeleteRun: async (runIdToDelete) => { - try { - await window.ipc.invoke('runs:delete', { runId: runIdToDelete }) - // Close any chat tab showing the deleted run - const tabForRun = chatTabs.find(t => t.runId === runIdToDelete) - if (tabForRun) { - if (chatTabs.length > 1) { - closeChatTab(tabForRun.id) - } 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 || isChatHistoryOpen || isBrowserOpen) { - handleNewChat() - } else { - void navigateToView({ type: 'chat', runId: null }) - } - } - } else if (runId === runIdToDelete) { - 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 { - void navigateToView({ type: 'chat', runId: null }) - } - } - await loadRuns() - } catch (err) { - console.error('Failed to delete run:', err) - } - }, - 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() - }} + activeNav={ + isHomeOpen ? 'home' + : isEmailOpen ? 'email' + : isMeetingsOpen ? 'meetings' + : (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge' + : isBgTasksOpen ? 'agents' + : isWorkspaceOpen ? 'workspaces' + : null + } onOpenMeetings={openMeetingsView} + onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} + onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} + recentRuns={runs} + onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onOpenEmail={(threadId) => openEmailView(threadId)} + onOpenHome={() => void navigateToView({ type: 'home' })} + onNewChat={handleNewChatTab} + onToggleBrowser={handleToggleBrowser} + onVoiceNoteCreated={handleVoiceNoteCreated} 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 || isChatHistoryOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + /> + ) : isFullScreenChat ? ( + { + const activeTab = chatTabs.find((t) => t.id === activeChatTabId) + return activeTab ? getChatTabTitle(activeTab) : 'New chat' + })()} + onNewChatTab={handleNewChatTab} + recentRuns={runs} + activeRunId={runId} + onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} + onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( + {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( - New chat tab + New chat )} {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && ( @@ -5241,7 +5242,7 @@ function App() { Restore two-pane view )} - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && ( + )} + +
+ {recentRuns.slice(0, 4).map((run) => ( + + ))} +
+ + )} + +
+
+ {recentRuns.length > 0 ? 'Or start fresh' : 'Get started'} +
+
+ {SUGGESTED_ACTIONS.map((action) => ( + + ))} +
+
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-header.tsx b/apps/x/apps/renderer/src/components/chat-header.tsx new file mode 100644 index 00000000..0350f895 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-header.tsx @@ -0,0 +1,114 @@ +import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { formatRelativeTime } from '@/lib/relative-time' + +export interface ChatHeaderRecentRun { + id: string + title?: string + createdAt: string +} + +export interface ChatHeaderProps { + activeTitle: string + onNewChatTab: () => void + recentRuns?: ChatHeaderRecentRun[] + activeRunId?: string | null + onSelectRun?: (runId: string) => void + onOpenChatHistory?: () => void +} + +/** + * Header controls for the copilot/chat surface: the active-chat title with a + * recent-chats history dropdown, plus the new-chat button. Rendered identically + * whether the chat lives in the side pane (ChatSidebar) or full screen (App + * content header). There is a single chat conversation at a time — switching + * between chats happens through the history dropdown. + */ +export function ChatHeader({ + activeTitle, + onNewChatTab, + recentRuns = [], + activeRunId, + onSelectRun, + onOpenChatHistory, +}: ChatHeaderProps) { + const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory) + + return ( + <> + {hasHistory ? ( + + + + + + {recentRuns.length > 0 && ( + + Recent + + )} + {recentRuns.slice(0, 6).map((run) => ( + onSelectRun?.(run.id)} + className={cn('gap-2', activeRunId === run.id && 'bg-accent')} + > + {run.title || '(Untitled chat)'} + + {formatRelativeTime(run.createdAt)} + + + ))} + {onOpenChatHistory && ( + <> + {recentRuns.length > 0 && } + + + View all chats + + + )} + + + ) : ( +
+ + {activeTitle} +
+ )} + + + + + New chat + + + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 3b4021c8..5dabddc7 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2, SquarePen } from 'lucide-react' +import { Maximize2, Minimize2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ChatHeader } from '@/components/chat-header' +import { ChatEmptyState } from '@/components/chat-empty-state' import { Conversation, ConversationContent, - ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation' import { @@ -22,13 +23,12 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c import { PermissionRequest } from '@/components/ai-elements/permission-request' import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' -import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' -import { TabBar, type ChatTab } from '@/components/tab-bar' +import { type ChatTab } from '@/components/tab-bar' import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' @@ -120,10 +120,10 @@ interface ChatSidebarProps { chatTabs: ChatTab[] activeChatTabId: string getChatTabTitle: (tab: ChatTab) => string - isChatTabProcessing: (tab: ChatTab) => boolean - onSwitchChatTab: (tabId: string) => void - onCloseChatTab: (tabId: string) => void onNewChatTab: () => void + recentRuns?: { id: string; title?: string; createdAt: string }[] + onSelectRun?: (runId: string) => void + onOpenChatHistory?: () => void onOpenFullScreen?: () => void conversation: ConversationItem[] currentAssistantMessage: string @@ -175,10 +175,10 @@ export function ChatSidebar({ chatTabs, activeChatTabId, getChatTabTitle, - isChatTabProcessing, - onSwitchChatTab, - onCloseChatTab, onNewChatTab, + recentRuns = [], + onSelectRun, + onOpenChatHistory, onOpenFullScreen, conversation, currentAssistantMessage, @@ -327,7 +327,6 @@ export function ChatSidebar({ if (tabId === activeChatTabId) return activeTabState return chatTabStates[tabId] ?? emptyTabState }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) - const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage) const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { @@ -499,28 +498,17 @@ export function ChatSidebar({ transition: isMaximized ? 'padding-left 200ms linear' : undefined, }} > - tab.id} - isProcessing={isChatTabProcessing} - onSwitchTab={onSwitchChatTab} - onCloseTab={onCloseChatTab} + { + const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) + return activeTab ? getChatTabTitle(activeTab) : 'New chat' + })()} + onNewChatTab={onNewChatTab} + recentRuns={recentRuns} + activeRunId={runId} + onSelectRun={onSelectRun} + onOpenChatHistory={onOpenChatHistory} /> - - - - - New chat tab - {onOpenFullScreen && ( @@ -565,11 +553,19 @@ export function ChatSidebar({ anchorRequestKey={viewportAnchors[tab.id]?.requestKey} className="relative flex-1" > - + {!tabHasConversation ? ( - -
Ask anything...
-
+ ) : ( <> {groupConversationItems( @@ -647,9 +643,6 @@ export function ChatSidebar({
- {!hasConversation && ( - - )} {chatTabs.map((tab) => { const isActive = tab.id === activeChatTabId const tabState = getTabState(tab.id) diff --git a/apps/x/apps/renderer/src/components/home-view.tsx b/apps/x/apps/renderer/src/components/home-view.tsx new file mode 100644 index 00000000..4534951c --- /dev/null +++ b/apps/x/apps/renderer/src/components/home-view.tsx @@ -0,0 +1,503 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ArrowRight, Bot, Calendar, Clock, FileText, Globe, Mail, MessageSquare, Mic, Plus, Telescope, Video } from 'lucide-react' + +import { VoiceNoteButton } from '@/components/sidebar-content' +import { extractConferenceLink } from '@/lib/calendar-event' + +interface TreeNode { + path: string + name: string + kind: 'file' | 'dir' + children?: TreeNode[] + stat?: { size: number; mtimeMs: number } +} + +type RunItem = { id: string; title?: string; createdAt: string } +type TaskItem = { slug: string; name: string; active: boolean; lastRunAt?: string; lastAttemptAt?: string } + +type HomeViewProps = { + tree: TreeNode[] + runs: RunItem[] + bgTaskSummaries: TaskItem[] + onOpenEmail: () => void + onOpenMeetings: () => void + onOpenAgents: () => void + onOpenAgent: (slug: string) => void + onOpenNote: (path: string) => void + onOpenRun: (runId: string) => void + onTakeMeetingNotes: () => void + onVoiceNoteCreated?: (path: string) => void + onRunBrowserTask?: () => void + onStartResearch?: () => void +} + +type CalEvent = { + id: string + summary: string + start: Date + end: Date | null + isAllDay: boolean + conferenceLink: string | null + rawStart: { dateTime?: string; date?: string } | undefined + rawEnd: { dateTime?: string; date?: string } | undefined + location: string | null + htmlLink: string | null + source: string +} + +type RawCalEvent = { + 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 }> +} + +type EmailThread = { threadId: string; subject: string; from: string } + +function greeting(): string { + const h = new Date().getHours() + if (h < 12) return 'Good morning' + if (h < 18) return 'Good afternoon' + return 'Good evening' +} + +function todayLabel(): string { + return new Date().toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }) +} + +function timeOfDay(d: Date): string { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function relativeFromNow(start: Date): string { + const ms = start.getTime() - Date.now() + if (ms <= 0) return 'now' + const min = Math.round(ms / 60000) + if (min < 60) return `in ${min}m` + const hr = Math.round(min / 60) + if (hr < 24) return `in ${hr}h` + return start.toLocaleDateString([], { weekday: 'short' }) +} + +function relativeAgo(iso?: string): string { + if (!iso) return '' + const t = new Date(iso).getTime() + if (Number.isNaN(t)) return '' + const min = Math.round((Date.now() - t) / 60000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h ago` + const d = Math.round(hr / 24) + return `${d}d ago` +} + +function parseAllDay(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 normalizeCalEvent(raw: RawCalEvent, sourcePath: string): CalEvent | null { + if (raw.status === 'cancelled') return null + const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined' + if (declined) return null + const timed = raw.start?.dateTime + const allDay = raw.start?.date + const isAllDay = !timed && Boolean(allDay) + let start: Date | null = null + let end: Date | null = null + if (timed) { + start = new Date(timed) + end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null + } else if (allDay) { + start = parseAllDay(allDay) + end = raw.end?.date ? parseAllDay(raw.end.date) : null + } + if (!start || Number.isNaN(start.getTime())) return null + return { + id: raw.id ?? sourcePath, + summary: raw.summary?.trim() || '(No title)', + start, + end, + isAllDay, + conferenceLink: extractConferenceLink(raw as unknown as Record) ?? null, + rawStart: raw.start, + rawEnd: raw.end, + location: raw.location?.trim() || null, + htmlLink: raw.htmlLink ?? null, + source: sourcePath, + } +} + +function noteLabel(node: TreeNode): string { + if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) return node.name.slice(0, -3) + return node.name +} + +const CARD = 'rounded-xl border border-border bg-card p-4' + +export function HomeView({ + tree, + runs, + bgTaskSummaries, + onOpenEmail, + onOpenMeetings, + onOpenAgents, + onOpenAgent, + onOpenNote, + onOpenRun, + onTakeMeetingNotes, + onVoiceNoteCreated, + onRunBrowserTask, + onStartResearch, +}: HomeViewProps) { + const [events, setEvents] = useState([]) + const [emails, setEmails] = useState([]) + + const loadEvents = useCallback(async () => { + try { + const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' }) + if (!exists.exists) { setEvents([]); 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' }) + return normalizeCalEvent(JSON.parse(result.data) as RawCalEvent, entry.path) + }), + ) + const out: CalEvent[] = [] + for (const r of settled) if (r.status === 'fulfilled' && r.value) out.push(r.value) + out.sort((a, b) => a.start.getTime() - b.start.getTime()) + setEvents(out) + } catch (err) { + console.error('Home: failed to load events', err) + } + }, []) + + const loadEmails = useCallback(async () => { + try { + const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 }) + setEmails( + result.threads + .filter((t) => t.unread === true) + .slice(0, 3) + .map((t) => ({ threadId: t.threadId, subject: t.subject ?? '(No subject)', from: t.from ?? '' })), + ) + } catch (err) { + console.error('Home: failed to load emails', err) + } + }, []) + + useEffect(() => { void loadEvents(); void loadEmails() }, [loadEvents, loadEmails]) + + // Upcoming (not-yet-ended) events, soonest first. + const upcoming = useMemo(() => { + const now = Date.now() + return events.filter((e) => { + const end = e.end ?? (e.isAllDay ? new Date(e.start.getTime() + 864e5) : e.start) + return end.getTime() > now + }) + }, [events]) + + const nextEvent = upcoming[0] + + const todaysEvents = useMemo(() => { + const now = new Date() + return upcoming.filter((e) => + e.start.getFullYear() === now.getFullYear() && + e.start.getMonth() === now.getMonth() && + e.start.getDate() === now.getDate(), + ) + }, [upcoming]) + + const activeAgents = useMemo(() => bgTaskSummaries.filter((t) => t.active), [bgTaskSummaries]) + const recentAgent = useMemo(() => { + const t = (s?: string) => (s ? new Date(s).getTime() || 0 : 0) + return [...bgTaskSummaries].sort((a, b) => + Math.max(t(b.lastRunAt), t(b.lastAttemptAt)) - Math.max(t(a.lastRunAt), t(a.lastAttemptAt)), + )[0] + }, [bgTaskSummaries]) + + const recentNotes = useMemo(() => { + const out: TreeNode[] = [] + const walk = (nodes: TreeNode[]) => { + for (const n of nodes) { + if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue + if (n.kind === 'file') out.push(n) + else if (n.children?.length) walk(n.children) + } + } + walk(tree) + return out + .filter((n) => n.stat?.mtimeMs) + .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) + .slice(0, 2) + }, [tree]) + + const recentActivity = useMemo(() => { + const items: Array<{ key: string; icon: 'note' | 'chat'; label: string; kind: string; when: number; open: () => void }> = [] + for (const n of recentNotes) { + items.push({ key: `n:${n.path}`, icon: 'note', label: noteLabel(n), kind: 'note', when: n.stat?.mtimeMs ?? 0, open: () => onOpenNote(n.path) }) + } + for (const r of runs.slice(0, 4)) { + items.push({ key: `r:${r.id}`, icon: 'chat', label: r.title || '(Untitled chat)', kind: 'chat', when: new Date(r.createdAt).getTime() || 0, open: () => onOpenRun(r.id) }) + } + return items.sort((a, b) => b.when - a.when).slice(0, 4) + }, [recentNotes, runs, onOpenNote, onOpenRun]) + + return ( +
+
+
+ + {/* Greeting */} +
+

{greeting()}

+ {todayLabel()} +
+ + {/* Up-next hero */} + {nextEvent && ( +
+
+ +
+
+
+ Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)} +
+
{nextEvent.summary}
+
+ {nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` – ${timeOfDay(nextEvent.end)}` : ''}`} + {nextEvent.location ? ` · ${nextEvent.location}` : ''} +
+
+
+ + {nextEvent.conferenceLink && ( + + )} +
+
+ )} + + {/* Inbox + Background agents */} +
+
+
+ + Inbox + {emails.length > 0 && ( + + {emails.length} new + + )} + + +
+ {emails.length === 0 ? ( +
No unread important email.
+ ) : emails.map((e, i) => ( + + ))} +
+ +
+
+ + Background agents + + {activeAgents.length} active +
+ {recentAgent ? ( + + ) : ( +
No agents yet.
+ )} + +
+
+ + {/* Today's schedule */} +
+
+ + Today's schedule + + +
+ {todaysEvents.length === 0 ? ( +
No more events today.
+ ) : todaysEvents.map((e, i) => ( +
+ + {e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` – ${timeOfDay(e.end)}` : ''}`} + + + {e.summary} +
+ ))} +
+ + {/* Recent activity */} + {recentActivity.length > 0 && ( +
+
+ + Recent activity +
+ {recentActivity.map((a, i) => ( + + ))} +
+ )} + + {/* Discovery carousel */} + , + }, + { + icon: Globe, + title: 'Run a browser task', + desc: 'let the agent drive a real browser to look things up and get things done.', + onAction: onRunBrowserTask, + }, + { + icon: Telescope, + title: 'Do extreme research', + desc: 'Rowboat digs deep and builds you a local website with the findings.', + onAction: onStartResearch, + }, + ]} + /> + +
+
+
+ ) +} + +type DiscoveryTip = { + icon: typeof Mic + title: string + desc: string + /** Provide one of: an action node (e.g. VoiceNoteButton) or an onAction handler. */ + action?: React.ReactNode + onAction?: () => void +} + +function DiscoveryCarousel({ tips }: { tips: DiscoveryTip[] }) { + const [index, setIndex] = useState(0) + const [paused, setPaused] = useState(false) + + useEffect(() => { + if (paused || tips.length <= 1) return + const id = setInterval(() => setIndex((i) => (i + 1) % tips.length), 7000) + return () => clearInterval(id) + }, [paused, tips.length]) + + const safeIndex = index % tips.length + const tip = tips[safeIndex] + const Icon = tip.icon + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + > +
+ +
+
+ {tip.title} + — {tip.desc} +
+ {tip.action ?? ( + + )} +
+ {tips.map((_, i) => ( +
+
+ ) +} + +function formatFrom(from: string): string { + const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from) + return (m ? m[1] : from).trim() +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 3053abc8..822aa8f1 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -4,37 +4,23 @@ import * as React from "react" import { useCallback, useEffect, useRef, useState } from "react" import { Bot, - ArrowUpRight, ChevronRight, - ExternalLink, FileText, FilePlus, Folder, - FolderPlus, Globe, AlertTriangle, Home, Mic, - SearchIcon, SquarePen, Plug, - Plus, - Video, LoaderIcon, Mail, MessageSquare, Settings, Square, - Trash2, + Video, } from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" import { AlertDialog, AlertDialogAction, @@ -46,7 +32,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" -import { Button } from "@/components/ui/button" import { Sidebar, SidebarContent, @@ -70,17 +55,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu" import { cn } from "@/lib/utils" import { SettingsDialog } from "@/components/settings-dialog" -import { toast } from "@/lib/toast" -import { formatRelativeTime as formatRunTime } from "@/lib/relative-time" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" import { ServiceEvent } from "@x/shared/src/service-events.js" @@ -119,11 +95,19 @@ function displayNoteName(node: TreeNode): string { return node.name } -type RunListItem = { - id: string - title?: string - createdAt: string - agentId: string +function formatAgo(ms: number): string { + const diffMs = Math.max(0, Date.now() - ms) + const min = Math.floor(diffMs / 60000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.floor(hr / 24) + if (day < 7) return `${day}d ago` + const wk = Math.floor(day / 7) + if (wk < 4) return `${wk}w ago` + const mo = Math.max(1, Math.floor(day / 30)) + return `${mo}mo ago` } type TaskSummary = { @@ -133,6 +117,7 @@ type TaskSummary = { createdAt: string lastAttemptAt?: string lastRunAt?: string + lastRunError?: string } type ServiceEventType = z.infer @@ -171,36 +156,27 @@ function collectServiceErrors(events: ServiceEventType[]): Map { return errors } -type TasksActions = { - onNewChat: () => void - onSelectRun: (runId: string) => void - onDeleteRun: (runId: string) => void - onOpenInNewTab?: (runId: string) => void - onSelectBackgroundTask?: (taskName: string) => void - onOpenChatHistoryView?: () => void -} - type SidebarContentPanelProps = { tree: TreeNode[] - selectedPath: string | null onSelectFile: (path: string, kind: "file" | "dir") => void knowledgeActions: KnowledgeActions - runs?: RunListItem[] - currentRunId?: string | null - processingRunIds?: Set - tasksActions?: TasksActions bgTaskSummaries?: TaskSummary[] - onOpenBgTask?: (slug: string) => void onOpenMeetings?: () => void - meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping' - recordingMeetingSource?: string | null - onToggleMeetingRecording?: () => void onOpenBgTasks?: () => void + onOpenAgent?: (slug: string) => void + recentRuns?: { id: string; title?: string; createdAt: string }[] + onOpenRun?: (runId: string) => void onOpenEmail?: (threadId?: string) => void onOpenHome?: () => void onNewChat?: () => void - onOpenSearch?: () => void onToggleBrowser?: () => void + onVoiceNoteCreated?: (path: string) => void + /** Which primary destination is currently active, for nav highlighting. */ + activeNav?: 'home' | 'email' | 'meetings' | 'knowledge' | 'agents' | 'workspaces' | null + /** Live meeting recording state, so the recording row can show its indicator/stop. */ + meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping' + recordingMeetingSource?: string | null + onToggleMeetingRecording?: () => void } & React.ComponentProps function formatEventTime(ts: string): string { @@ -430,25 +406,23 @@ function SyncStatusBar() { export function SidebarContentPanel({ tree, - selectedPath, onSelectFile, knowledgeActions, - runs = [], - currentRunId, - processingRunIds, - tasksActions, bgTaskSummaries = [], - onOpenBgTask, onOpenMeetings, - meetingRecordingState, - recordingMeetingSource, - onToggleMeetingRecording, onOpenBgTasks, + onOpenAgent, + recentRuns = [], + onOpenRun, onOpenEmail, onOpenHome, onNewChat, - onOpenSearch, onToggleBrowser, + onVoiceNoteCreated, + activeNav, + meetingRecordingState = 'idle', + recordingMeetingSource = null, + onToggleMeetingRecording, ...props }: SidebarContentPanelProps) { const [hasOauthError, setHasOauthError] = useState(false) @@ -461,6 +435,197 @@ export function SidebarContentPanel({ const [appUrl, setAppUrl] = useState(null) const { billing } = useBilling(isRowboatConnected) + // Nav previews: unread important emails + next upcoming meetings (top 2 each). + const [unreadEmailCount, setUnreadEmailCount] = useState(0) + const [emailThreads, setEmailThreads] = useState([]) + const [meetings, setMeetings] = useState([]) + const [quickAccessExpanded, setQuickAccessExpanded] = useState(true) + + useEffect(() => { + let cancelled = false + const loadEmail = async () => { + try { + const result = await window.ipc.invoke('gmail:getImportant', { limit: 50 }) + if (cancelled) return + const unread = result.threads.filter((t) => t.unread === true) + setUnreadEmailCount(unread.length) + setEmailThreads(unread.slice(0, 1).map((t) => ({ + threadId: t.threadId, + subject: t.subject ?? '(No subject)', + from: t.from ?? '', + date: t.date ?? '', + }))) + } catch { /* ignore */ } + } + void loadEmail() + const cleanup = window.ipc.on('workspace:didChange', (event) => { + const paths = event.type === 'bulkChanged' ? (event.paths ?? []) + : event.type === 'moved' ? [event.from, event.to] + : 'path' in event ? [event.path] : [] + if (paths.some((p) => typeof p === 'string' && p.startsWith('gmail_sync'))) void loadEmail() + }) + return () => { cancelled = true; cleanup() } + }, []) + + useEffect(() => { + let cancelled = false + const loadNext = async () => { + try { + const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' }) + if (!exists.exists) { if (!cancelled) setNextMeetingLabel(null); 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) => { + const result = await window.ipc.invoke('workspace:readFile', { path: entry.path, encoding: 'utf8' }) + return normalizeUpcomingMeeting(JSON.parse(result.data) as RawCalendarEvent, entry.path) + })) + const items: UpcomingMeeting[] = [] + for (const r of settled) if (r.status === 'fulfilled' && r.value) items.push(r.value) + items.sort((a, b) => { + if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1 + return a.start.getTime() - b.start.getTime() + }) + if (!cancelled) setMeetings(items.slice(0, 1)) + } catch { /* ignore */ } + } + void loadNext() + const cleanup = window.ipc.on('workspace:didChange', (event) => { + const paths = event.type === 'bulkChanged' ? (event.paths ?? []) + : event.type === 'moved' ? [event.from, event.to] + : 'path' in event ? [event.path] : [] + if (paths.some((p) => typeof p === 'string' && p.startsWith('calendar_sync'))) void loadNext() + }) + const tick = setInterval(() => void loadNext(), 60 * 60 * 1000) + return () => { cancelled = true; clearInterval(tick); cleanup() } + }, []) + + const recentNotes = React.useMemo(() => { + const out: TreeNode[] = [] + const walk = (nodes: TreeNode[]) => { + for (const n of nodes) { + if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue + if (n.kind === 'file') out.push(n) + else if (n.children?.length) walk(n.children) + } + } + walk(tree) + return out + .filter((n) => n.stat?.mtimeMs) + .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) + .slice(0, 5) + }, [tree]) + + // Recents: most recently touched notes / agents / chats, interleaved by + // recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 overall. + type QuickAccessItem = { + key: string + label: string + recency: number + type: 'note' | 'agent' | 'chat' + onClick: () => void + } + const quickAccessItems = React.useMemo(() => { + const items: QuickAccessItem[] = [] + + for (const note of recentNotes.slice(0, 3)) { + items.push({ + key: `note:${note.path}`, + label: displayNoteName(note), + recency: note.stat?.mtimeMs ?? 0, + type: 'note', + onClick: () => onSelectFile(note.path, 'file'), + }) + } + + const agentRecency = (t: TaskSummary) => { + const ts = t.lastRunAt ?? t.lastAttemptAt ?? t.createdAt + const ms = ts ? new Date(ts).getTime() : 0 + return Number.isFinite(ms) ? ms : 0 + } + for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 2)) { + items.push({ + key: `agent:${t.slug}`, + label: t.name, + recency: agentRecency(t), + type: 'agent', + onClick: () => onOpenAgent?.(t.slug), + }) + } + + const chatRecency = (r: { createdAt: string }) => { + const ms = new Date(r.createdAt).getTime() + return Number.isFinite(ms) ? ms : 0 + } + for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 1)) { + items.push({ + key: `chat:${r.id}`, + label: r.title || '(Untitled chat)', + recency: chatRecency(r), + type: 'chat', + onClick: () => onOpenRun?.(r.id), + }) + } + + return items.sort((a, b) => b.recency - a.recency).slice(0, 5) + }, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun]) + + // Workspace count for the Workspaces sublabel — top-level dir children of + // knowledge/Workspace (matches WorkspaceView's root listing). + const workspaceCount = React.useMemo(() => { + const find = (nodes: TreeNode[]): TreeNode | null => { + for (const n of nodes) { + if (n.path === 'knowledge/Workspace') return n + if (n.kind === 'dir' && n.children?.length) { + const found = find(n.children) + if (found) return found + } + } + return null + } + const node = find(tree) + return node?.children?.filter((c) => c.kind === 'dir').length ?? 0 + }, [tree]) + + // "Updated 4m ago" sublabel under Knowledge, based on the most recently + // modified note. Recomputed in an effect (not during render) and ticked so + // the relative time stays fresh. + const latestNoteMtime = recentNotes[0]?.stat?.mtimeMs ?? null + const [knowledgeUpdatedLabel, setKnowledgeUpdatedLabel] = useState(null) + useEffect(() => { + if (!latestNoteMtime) { setKnowledgeUpdatedLabel(null); return } + const update = () => setKnowledgeUpdatedLabel(`Updated ${formatAgo(latestNoteMtime)}`) + update() + const tick = setInterval(update, 60 * 1000) + return () => clearInterval(tick) + }, [latestNoteMtime]) + + // "2 active · Last run 3m ago" sublabel under Background agents, overridden by + // "N failed · Needs review" when any task's last run errored. + const [bgAgentsLabel, setBgAgentsLabel] = useState(null) + useEffect(() => { + const update = () => { + const failed = bgTaskSummaries.filter((t) => t.lastRunError).length + if (failed > 0) { + setBgAgentsLabel(`${failed} failed · Needs review`) + return + } + const active = bgTaskSummaries.filter((t) => t.active).length + const lastRunMs = bgTaskSummaries.reduce((max, t) => { + const ms = t.lastRunAt ? new Date(t.lastRunAt).getTime() : 0 + return Number.isFinite(ms) && ms > max ? ms : max + }, 0) + const parts: string[] = [active > 0 ? `${active} active` : 'No active agents'] + if (lastRunMs > 0) parts.push(`Last run ${formatAgo(lastRunMs)}`) + setBgAgentsLabel(parts.join(' · ')) + } + update() + const tick = setInterval(update, 60 * 1000) + return () => clearInterval(tick) + }, [bgTaskSummaries]) + const handleRowboatLogin = useCallback(async () => { try { setLoggingIn(true) @@ -517,88 +682,237 @@ export function SidebarContentPanel({ } }, []) + // Single preview shown as a sublabel on the Email / Meetings nav buttons. + const previewEmail = emailThreads[0] + const previewMeeting = meetings[0] + const meetingIsRecording = previewMeeting != null + && recordingMeetingSource === previewMeeting.source + && (meetingRecordingState === 'recording' || meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') + const meetingIsBusy = meetingIsRecording && (meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') + return ( {/* Top spacer to clear the traffic lights + fixed toggle row */}
-
- {onOpenHome && ( - - )} + {/* Quick actions */} +
{onNewChat && ( - + )} + knowledgeActions.createNote()} /> + {onToggleBrowser && ( - - )} - {onOpenSearch && ( - + )}
- setConnectionsSettingsOpen(true)} - /> - setConnectionsSettingsOpen(true)} - recordingState={meetingRecordingState ?? 'idle'} - recordingSource={recordingMeetingSource ?? null} - onToggleRecording={onToggleMeetingRecording} - /> - - - - + {/* Primary navigation */} + + + + + + + Home + + + + onOpenEmail?.()} + className={previewEmail ? 'h-auto py-1.5' : undefined} + > + +
+ Email + {previewEmail && ( + + {formatEmailFrom(previewEmail.from)} · {previewEmail.subject} + + )} +
+ {unreadEmailCount > 0 && ( + + {unreadEmailCount} + + )} +
+
+ + + +
+ Meetings + {previewMeeting && ( + + {meetingIsRecording ? previewMeeting.summary : `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}`} + + )} +
+
+ {previewMeeting && (meetingIsRecording ? ( +
+ + + + + + + + + + {meetingRecordingState === 'connecting' ? 'Starting…' : meetingRecordingState === 'stopping' ? 'Stopping…' : 'Stop recording'} + + +
+ ) : ( +
+ + + + + Take notes + + {previewMeeting.conferenceLink && ( + + + + + Join & take notes + + )} +
+ ))} +
+ + knowledgeActions.openKnowledgeView()} + className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined} + > + +
+ Knowledge + {knowledgeUpdatedLabel && ( + {knowledgeUpdatedLabel} + )} +
+
+
+
+ +
+ + + + + +
+ Background agents + {bgAgentsLabel && ( + t.lastRunError) ? 'text-destructive' : 'text-muted-foreground', + )}> + {bgAgentsLabel} + + )} +
+
+
+ + knowledgeActions.openWorkspaceAt()} + className="h-auto py-1.5" + > + +
+ Workspaces + + {workspaceCount === 0 ? 'No workspaces' : `${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'}`} + +
+
+
+
+ + + +
+ + {/* Recents */} + + + + {quickAccessExpanded && ( + quickAccessItems.length === 0 ? ( +
+ Recent notes and agents show up here. +
+ ) : ( + + {quickAccessItems.map((item) => ( + + + {item.type === 'agent' ? ( + + ) : item.type === 'chat' ? ( + + ) : ( + + )} + {item.label} + + + ))} + + ) + )} +
+
{/* Billing / upgrade CTA or Log in CTA */} {isRowboatConnected && billing ? ( @@ -753,7 +1067,7 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise { } // Voice Note Recording Button -export function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) { +export function VoiceNoteButton({ onNoteCreated, variant = 'icon' }: { onNoteCreated?: (path: string) => void; variant?: 'icon' | 'action' }) { const [isRecording, setIsRecording] = React.useState(false) const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false) const mediaRecorderRef = React.useRef(null) @@ -944,12 +1258,17 @@ path: ${currentRelativePath} if (!hasDeepgramKey) return null + const actionClass = "flex h-9 flex-1 items-center justify-center rounded-md border border-sidebar-border text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors" + const iconClass = "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors" + return ( + + {label} + ) } -export function WorkspaceSection({ - tree, - actions, -}: { - tree: TreeNode[] - actions: KnowledgeActions -}) { - const recentWorkspaces = React.useMemo(() => { - const root = tree.find((item) => item.path === 'knowledge/Workspace') - const children = root?.children ?? [] - return [...children] - .filter((c) => c.kind === 'dir') - .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) - .slice(0, 3) - }, [tree]) - - return ( - -
- Workspace -
- - - {recentWorkspaces.map((ws) => ( - - actions.openWorkspaceAt(ws.path)}> - - {ws.name} - - - ))} - - actions.openWorkspaceAt()}> - {recentWorkspaces.length === 0 ? ( - <> - - New workspace - - ) : ( - <> - - View all - - )} - - - - -
- ) -} - - type UpcomingMeeting = { id: string summary: string @@ -1173,6 +1367,21 @@ function normalizeUpcomingMeeting(raw: RawCalendarEvent, sourcePath: string): Up } } +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' }) +} + function triggerMeetingCapture(event: UpcomingMeeting, openConference: boolean) { window.__pendingCalendarEvent = { summary: event.summary, @@ -1189,21 +1398,6 @@ function triggerMeetingCapture(event: UpcomingMeeting, openConference: boolean) 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 @@ -1217,541 +1411,3 @@ function formatEmailFrom(from: string): string { 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, -}: { - runs: RunListItem[] - currentRunId?: string | null - processingRunIds?: Set - actions?: TasksActions -}) { - const [pendingDeleteRunId, setPendingDeleteRunId] = useState(null) - - return ( - - -
- Chat history -
- - {runs.slice(0, 3).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 - - - )} - -
- ))} - {runs.length > 0 && actions?.onOpenChatHistoryView && ( - - actions.onOpenChatHistoryView?.()}> - - View all - - - )} -
-
- - {/* Delete confirmation dialog */} - { if (!open) setPendingDeleteRunId(null) }}> - - - Delete chat - - Are you sure you want to delete this chat? - - - - - - - - -
- ) -}