mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
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:
parent
e6587a67b7
commit
fbd0791d0c
6 changed files with 1467 additions and 1052 deletions
|
|
@ -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}
|
||||
|
|
|
|||
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal file
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal file
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
503
apps/x/apps/renderer/src/components/home-view.tsx
Normal file
503
apps/x/apps/renderer/src/components/home-view.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue