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) <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-05-22 15:47:20 +05:30 committed by arkml
parent e6587a67b7
commit fbd0791d0c
6 changed files with 1467 additions and 1052 deletions

View file

@ -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<string | null>(null)
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
const [isHomeOpen, setIsHomeOpen] = useState(false)
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
const [expandedFrom, setExpandedFrom] = useState<{
@ -1009,13 +1016,13 @@ function App() {
// Chat tab state
const [chatTabs, setChatTabs] = useState<ChatTab[]>([{ id: 'default-chat-tab', runId: null }])
const chatTabsRef = useRef(chatTabs)
chatTabsRef.current = chatTabs
const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
const [chatViewStateByTab, setChatViewStateByTab] = useState<Record<string, ChatTabViewState>>({
'default-chat-tab': createEmptyChatTabViewState(),
})
const chatViewStateByTabRef = useRef(chatViewStateByTab)
const chatTabIdCounterRef = useRef(0)
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
const chatDraftsRef = useRef(new Map<string, string>())
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
const chatScrollTopByTabRef = useRef(new Map<string, number>())
@ -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<string | null>(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 (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
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() {
>
<SidebarContentPanel
tree={tree}
selectedPath={selectedPath}
onSelectFile={toggleExpand}
knowledgeActions={knowledgeActions}
runs={runs}
currentRunId={runId}
processingRunIds={processingRunIds}
tasksActions={{
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
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}
/>
<SidebarInset
className={cn(
@ -5150,7 +5139,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(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 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -5158,7 +5147,19 @@ function App() {
getTabId={(t) => 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 ? (
<ChatHeader
activeTitle={(() => {
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' })}
/>
) : (
<TabBar
@ -5211,19 +5212,19 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!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 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleNewChatTab}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
aria-label="New chat tab"
aria-label="New chat"
>
<SquarePen className="size-5" />
<Plus className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat tab</TooltipContent>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
@ -5241,7 +5242,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -5265,6 +5266,24 @@ function App() {
onClose={handleCloseBrowser}
forceHidden={isSearchOpen || showMeetingPermissions}
/>
) : isHomeOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<HomeView
tree={tree}
runs={runs}
bgTaskSummaries={bgTaskSummaries}
onOpenEmail={() => openEmailView()}
onOpenMeetings={openMeetingsView}
onOpenAgents={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenNote={(path) => navigateToFile(path)}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
onVoiceNoteCreated={handleVoiceNoteCreated}
onRunBrowserTask={handleToggleBrowser}
onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)}
/>
</div>
) : isSuggestedTopicsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SuggestedTopicsView
@ -5350,7 +5369,6 @@ function App() {
currentRunId={runId}
processingRunIds={processingRunIds}
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onOpenInNewTab={(rid) => openChatInNewTab(rid)}
onDeleteRun={async (rid) => {
try {
await window.ipc.invoke('runs:delete', { runId: rid })
@ -5555,7 +5573,24 @@ function App() {
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{chatTabs.map((tab) => {
{(activeChatTabState.conversation.length === 0 && !activeChatTabState.currentAssistantMessage) ? (
<HomeView
tree={tree}
runs={runs}
bgTaskSummaries={bgTaskSummaries}
onOpenEmail={() => openEmailView()}
onOpenMeetings={openMeetingsView}
onOpenAgents={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenNote={(path) => navigateToFile(path)}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
onVoiceNoteCreated={handleVoiceNoteCreated}
onRunBrowserTask={handleToggleBrowser}
onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)}
/>
) : (
chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getChatTabStateForRender(tab.id)
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
@ -5581,11 +5616,13 @@ function App() {
>
<ConversationContent className={tabConversationContentClassName}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
What are we working on?
</div>
</ConversationEmptyState>
<ChatEmptyState
wide
recentRuns={runs}
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
onPickPrompt={setPresetMessage}
/>
) : (
<>
{groupConversationItems(
@ -5657,15 +5694,13 @@ function App() {
</Conversation>
</div>
)
})}
})
)}
</div>
<div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-4">
{!hasConversation && (
<Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getChatTabStateForRender(tab.id)
@ -5729,10 +5764,18 @@ function App() {
chatTabs={chatTabs}
activeChatTabId={activeChatTabId}
getChatTabTitle={getChatTabTitle}
isChatTabProcessing={isChatTabProcessing}
onSwitchChatTab={switchChatTab}
onCloseChatTab={closeChatTab}
onNewChatTab={handleNewChatTabInSidebar}
recentRuns={runs}
onSelectRun={(rid) => {
const existingTab = chatTabs.find((t) => t.runId === rid)
if (existingTab) {
switchChatTab(existingTab.id)
return
}
setChatTabs((prev) => prev.map((t) => (t.id === activeChatTabId ? { ...t, runId: rid } : t)))
loadRun(rid)
}}
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
onOpenFullScreen={toggleRightPaneMaximize}
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}

View file

@ -0,0 +1,106 @@
import { ArrowUpRight, Bot, Mail, MessageSquare, NotebookPen, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatRelativeTime } from '@/lib/relative-time'
export interface ChatEmptyStateRun {
id: string
title?: string
createdAt: string
}
interface ChatEmptyStateProps {
recentRuns?: ChatEmptyStateRun[]
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
/** Fill the composer with a starter prompt (does not submit). */
onPickPrompt: (prompt: string) => void
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
wide?: boolean
}
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
{ icon: Bot, title: 'Set up a background agent', sub: 'that summarizes my inbox', prompt: 'Set up a background agent that summarizes my inbox each morning' },
{ icon: NotebookPen, title: 'Take notes', sub: 'while I work', prompt: 'Take notes while I work' },
]
/**
* Empty-state body for the chat surface: greeting, recent chats, and starter
* action cards. Shown in both the side-pane copilot and full-screen chat.
*/
export function ChatEmptyState({
recentRuns = [],
onSelectRun,
onOpenChatHistory,
onPickPrompt,
wide = false,
}: ChatEmptyStateProps) {
return (
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
<Sparkles className="size-[17px]" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">How can I help?</div>
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
</div>
</div>
{recentRuns.length > 0 && (
<div>
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="flex-1">Recent chats</span>
{onOpenChatHistory && (
<button
type="button"
onClick={onOpenChatHistory}
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
>
View all
<ArrowUpRight className="size-3" />
</button>
)}
</div>
<div className="flex flex-col gap-0.5">
{recentRuns.slice(0, 4).map((run) => (
<button
key={run.id}
type="button"
onClick={() => onSelectRun?.(run.id)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
</button>
))}
</div>
</div>
)}
<div>
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
</div>
<div className="flex flex-col gap-2">
{SUGGESTED_ACTIONS.map((action) => (
<button
key={action.title}
type="button"
onClick={() => onPickPrompt(action.prompt)}
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-[12.8px] font-medium">{action.title}</div>
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -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 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 text-sm font-medium text-foreground outline-none hover:bg-accent/60"
aria-label="Chat history"
>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{recentRuns.length > 0 && (
<DropdownMenuLabel className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</DropdownMenuLabel>
)}
{recentRuns.slice(0, 6).map((run) => (
<DropdownMenuItem
key={run.id}
onClick={() => onSelectRun?.(run.id)}
className={cn('gap-2', activeRunId === run.id && 'bg-accent')}
>
<span className="min-w-0 flex-1 truncate">{run.title || '(Untitled chat)'}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelativeTime(run.createdAt)}
</span>
</DropdownMenuItem>
))}
{onOpenChatHistory && (
<>
{recentRuns.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onOpenChatHistory} className="gap-2 text-primary">
<ArrowUpRight className="size-4" />
View all chats
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 text-sm font-medium text-foreground">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{activeTitle}</span>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="New chat"
>
<Plus className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</>
)
}

View file

@ -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,
}}
>
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(tab) => tab.id}
isProcessing={isChatTabProcessing}
onSwitchTab={onSwitchChatTab}
onCloseTab={onCloseChatTab}
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onNewChatTab}
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<SquarePen className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
@ -565,11 +553,19 @@ export function ChatSidebar({
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
className="relative flex-1"
>
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
<ConversationContent className={cn(
'mx-auto w-full max-w-4xl px-3',
tabHasConversation ? 'pb-28' : 'pb-0',
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
)}>
{!tabHasConversation ? (
<ConversationEmptyState className="h-auto">
<div className="text-sm text-muted-foreground">Ask anything...</div>
</ConversationEmptyState>
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : (
<>
{groupConversationItems(
@ -647,9 +643,6 @@ export function ChatSidebar({
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-3">
{!hasConversation && (
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
)}
{chatTabs.map((tab) => {
const isActive = tab.id === activeChatTabId
const tabState = getTabState(tab.id)

View file

@ -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<string, unknown>) ?? 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<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
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<CalEvent | null> => {
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<TreeNode[]>(() => {
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 (
<div className="flex h-full flex-col overflow-hidden bg-muted/30">
<div className="flex-1 overflow-y-auto px-9 py-7">
<div className="mx-auto flex max-w-[760px] flex-col gap-[18px]">
{/* Greeting */}
<div className="flex items-baseline gap-3">
<h1 className="text-[28px] font-semibold tracking-tight">{greeting()}</h1>
<span className="text-sm text-muted-foreground">{todayLabel()}</span>
</div>
{/* Up-next hero */}
{nextEvent && (
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
<Mic className="size-[22px]" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
</div>
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
<div className="truncate text-[13px] text-background/70">
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` ${timeOfDay(nextEvent.end)}` : ''}`}
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={onTakeMeetingNotes}
className="rounded-md bg-background px-3.5 py-2 text-[13px] font-medium text-foreground"
>
Take notes
</button>
{nextEvent.conferenceLink && (
<button
type="button"
onClick={() => window.open(nextEvent.conferenceLink!, '_blank')}
className="rounded-md border border-background/20 px-3 py-2 text-background"
aria-label="Join meeting"
>
<Video className="size-[13px]" />
</button>
)}
</div>
</div>
)}
{/* Inbox + Background agents */}
<div className="grid grid-cols-2 gap-[18px]">
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Mail className="size-[15px]" />
<span className="text-sm font-medium">Inbox</span>
{emails.length > 0 && (
<span className="rounded-lg bg-destructive px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-wide text-white">
{emails.length} new
</span>
)}
<span className="flex-1" />
<button type="button" onClick={onOpenEmail} className="text-xs text-primary hover:underline">Open </button>
</div>
{emails.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No unread important email.</div>
) : emails.map((e, i) => (
<button
key={e.threadId}
type="button"
onClick={onOpenEmail}
className={`flex w-full gap-2.5 py-[7px] text-left text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<span className="w-[92px] shrink-0 truncate text-muted-foreground">{formatFrom(e.from)}</span>
<span className="flex-1 truncate">{e.subject}</span>
</button>
))}
</div>
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Bot className="size-[15px]" />
<span className="text-sm font-medium">Background agents</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
</div>
{recentAgent ? (
<button
type="button"
onClick={() => onOpenAgent(recentAgent.slug)}
className="flex w-full items-center gap-2.5 py-[7px] text-left text-[13px]"
>
<span className={`size-2 shrink-0 rounded-full ${recentAgent.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`} />
<span className="flex-1 truncate font-medium">{recentAgent.name}</span>
<span className="text-[11.5px] text-muted-foreground">{relativeAgo(recentAgent.lastRunAt) || '—'}</span>
</button>
) : (
<div className="py-1 text-[12.5px] text-muted-foreground">No agents yet.</div>
)}
<button
type="button"
onClick={onOpenAgents}
className="mt-3.5 flex items-center gap-2 border-t border-border pt-3 text-[12.5px] text-primary"
>
<Plus className="size-3" />
Create an agent
</button>
</div>
</div>
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">
<Calendar className="size-[14px]" />
<span className="text-sm font-medium">Today's schedule</span>
<span className="flex-1" />
<button type="button" onClick={onOpenMeetings} className="text-xs text-primary hover:underline">All meetings </button>
</div>
{todaysEvents.length === 0 ? (
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
) : todaysEvents.map((e, i) => (
<div key={e.id} className={`flex items-center gap-3.5 py-2 text-[13px] ${i ? 'border-t border-border' : ''}`}>
<span className="w-[90px] shrink-0 font-mono text-[11.5px] text-muted-foreground">
{e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` ${timeOfDay(e.end)}` : ''}`}
</span>
<span className={`size-2 shrink-0 rounded-full ${i === 0 ? 'bg-emerald-500' : 'bg-border'}`} />
<span className="flex-1 truncate font-medium">{e.summary}</span>
</div>
))}
</div>
{/* Recent activity */}
{recentActivity.length > 0 && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<Clock className="size-[14px]" />
<span className="text-sm font-medium">Recent activity</span>
</div>
{recentActivity.map((a, i) => (
<button
key={a.key}
type="button"
onClick={a.open}
className={`flex w-full items-center gap-3 py-2 text-left text-[13px] ${i ? 'border-t border-border' : ''}`}
>
{a.icon === 'note' ? <FileText className="size-[13px] shrink-0 text-muted-foreground" /> : <MessageSquare className="size-[13px] shrink-0 text-muted-foreground" />}
<span className="flex-1 truncate">{a.label}</span>
<span className="w-[60px] text-right text-[11px] text-muted-foreground">{a.kind}</span>
</button>
))}
</div>
)}
{/* Discovery carousel */}
<DiscoveryCarousel
tips={[
{
icon: Mic,
title: 'Try a voice note',
desc: 'capture a thought out loud; the agent transcribes it and files it into Knowledge.',
action: <VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />,
},
{
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,
},
]}
/>
</div>
</div>
</div>
)
}
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 (
<div
className={`${CARD} flex items-center gap-3.5 border-dashed bg-transparent`}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card">
<Icon className="size-[15px]" />
</div>
<div className="flex-1 text-[13.5px] leading-snug">
<span className="font-medium">{tip.title}</span>
<span className="text-muted-foreground"> {tip.desc}</span>
</div>
{tip.action ?? (
<button
type="button"
onClick={() => tip.onAction?.()}
aria-label={tip.title}
className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ArrowRight className="size-[15px]" />
</button>
)}
<div className="ml-1 flex shrink-0 items-center gap-1">
{tips.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show tip ${i + 1}`}
className={`size-1.5 rounded-full transition-colors ${i === safeIndex ? 'bg-foreground' : 'bg-border hover:bg-muted-foreground/40'}`}
/>
))}
</div>
</div>
)
}
function formatFrom(from: string): string {
const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
return (m ? m[1] : from).trim()
}

File diff suppressed because it is too large Load diff