diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index deeb045a..4904a119 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, 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'; @@ -757,7 +757,8 @@ function App() { const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - const [isHomeOpen, setIsHomeOpen] = useState(false) + // Default landing view: Home in the middle with the chat docked on the right. + const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) const [expandedFrom, setExpandedFrom] = useState<{ @@ -1098,8 +1099,8 @@ function App() { }, [processingRunIds]) // File tab state - const [fileTabs, setFileTabs] = useState([]) - const [activeFileTabId, setActiveFileTabId] = useState(null) + const [fileTabs, setFileTabs] = useState([{ id: 'home-tab', path: HOME_TAB_PATH }]) + const [activeFileTabId, setActiveFileTabId] = useState('home-tab') const activeFileTabIdRef = useRef(activeFileTabId) activeFileTabIdRef.current = activeFileTabId const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) @@ -3272,9 +3273,10 @@ function App() { return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener) }, [submitFromPalette]) - const toggleKnowledgePane = useCallback(() => { + // Reveal the chat in the right side pane (from the middle-panel chat icon). + const openChatSidePane = useCallback(() => { setIsRightPaneMaximized(false) - setIsChatSidebarOpen(prev => !prev) + setIsChatSidebarOpen(true) }, []) // Browser is an overlay on the middle pane: opening it forces the chat @@ -3810,6 +3812,18 @@ function App() { await applyViewState(nextView) }, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay]) + // Move the maximized/full-screen chat into the right side pane: restore the + // view we expanded from (or fall back to Home) and dock the chat on the right. + const pushChatToSidePane = useCallback(() => { + setIsRightPaneMaximized(false) + setIsChatSidebarOpen(true) + if (expandedFrom) { + handleCloseFullScreenChat() + } else { + void navigateToView({ type: 'home' }) + } + }, [expandedFrom, handleCloseFullScreenChat, navigateToView]) + const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current if (back.length === 0) return @@ -4412,14 +4426,23 @@ function App() { } }, createFolder: async (parentPath: string = 'knowledge'): Promise => { - const newPath = `${parentPath}/new-folder-${Date.now()}` try { + let index = 1 + let name = 'New folder' + let fullPath = `${parentPath}/${name}` + while (index < 1000) { + const exists = await window.ipc.invoke('workspace:exists', { path: fullPath }) + if (!exists.exists) break + index += 1 + name = `New folder ${index}` + fullPath = `${parentPath}/${name}` + } await window.ipc.invoke('workspace:mkdir', { - path: newPath, + path: fullPath, recursive: true }) setExpandedPaths(prev => new Set([...prev, parentPath])) - return newPath + return fullPath } catch (err) { console.error('Failed to create folder:', err) throw err @@ -5227,38 +5250,38 @@ function App() { New chat )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && ( - - - - - Restore two-pane view - - )} - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && ( - - - - - - {isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"} - - - )} + {/* Trailing layout control. Always mounted (just toggled invisible + when inactive) so its -webkit-app-region:no-drag rect is stable — + a freshly-mounted no-drag button inside the drag-region header + otherwise has its first click swallowed by the window drag. */} + {(() => { + const viewOpen = selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen + const action = isFullScreenChat + ? { onClick: pushChatToSidePane, icon: , label: 'Dock chat to side pane' } + : (viewOpen && !isChatSidebarOpen) + ? { onClick: openChatSidePane, icon: , label: 'Open chat' } + : null + return ( + + + + + {action && {action.label}} + + ) + })()} {isBrowserOpen ? ( @@ -5282,6 +5305,7 @@ function App() { 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)} + onOpenChat={handleNewChatTab} /> ) : isSuggestedTopicsOpen ? ( @@ -5573,24 +5597,7 @@ function App() { { navigateToFile(path) }}>
- {(activeChatTabState.conversation.length === 0 && !activeChatTabState.currentAssistantMessage) ? ( - 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) => { + {chatTabs.map((tab) => { const isActive = tab.id === activeChatTabId const tabState = getChatTabStateForRender(tab.id) const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage @@ -5694,8 +5701,7 @@ function App() {
) - }) - )} + })}
@@ -5777,6 +5783,7 @@ function App() { }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} + onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }} conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} diff --git a/apps/x/apps/renderer/src/components/chat-empty-state.tsx b/apps/x/apps/renderer/src/components/chat-empty-state.tsx index f9f780ad..8febf651 100644 --- a/apps/x/apps/renderer/src/components/chat-empty-state.tsx +++ b/apps/x/apps/renderer/src/components/chat-empty-state.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, Bot, Mail, MessageSquare, NotebookPen, Sparkles } from 'lucide-react' +import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'lucide-react' import { cn } from '@/lib/utils' import { formatRelativeTime } from '@/lib/relative-time' @@ -21,8 +21,8 @@ interface ChatEmptyStateProps { 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' }, + { icon: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' }, + { icon: Telescope, title: 'Research a topic', sub: 'create a local wiki for me', prompt: 'Research [topic] and create a local wiki for me' }, ] /** diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 5dabddc7..0ff286e3 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2 } from 'lucide-react' +import { ArrowRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -125,6 +125,7 @@ interface ChatSidebarProps { onSelectRun?: (runId: string) => void onOpenChatHistory?: () => void onOpenFullScreen?: () => void + onCloseChat?: () => void conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record @@ -180,6 +181,7 @@ export function ChatSidebar({ onSelectRun, onOpenChatHistory, onOpenFullScreen, + onCloseChat, conversation, currentAssistantMessage, chatTabStates = {}, @@ -509,23 +511,40 @@ export function ChatSidebar({ onSelectRun={onSelectRun} onOpenChatHistory={onOpenChatHistory} /> - {onOpenFullScreen && ( - - - - - - {isMaximized ? 'Restore two-pane view' : 'Maximize chat view'} - - + {isMaximized ? ( + onOpenFullScreen && ( + + + + + Dock to side pane + + ) + ) : ( + onCloseChat && ( + + + + + Close chat + + ) )} diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 49566d11..f2fc5ca3 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' +import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -8,6 +8,7 @@ import type { blocks } from '@x/shared' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { useTheme } from '@/contexts/theme-context' +import { SettingsDialog } from '@/components/settings-dialog' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage @@ -842,6 +843,24 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current) const [error, setError] = useState(null) const [query, setQuery] = useState('') + // Gmail connection status — null while checking, false shows the connect prompt. + const [emailConnected, setEmailConnected] = useState(null) + const [settingsOpen, setSettingsOpen] = useState(false) + + useEffect(() => { + let cancelled = false + const check = async () => { + try { + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + if (!cancelled) setEmailConnected(result.isConnected) + } catch { + if (!cancelled) setEmailConnected(false) + } + } + void check() + const cleanup = window.ipc.on('oauth:didConnect', () => { void check() }) + return () => { cancelled = true; cleanup() } + }, []) useEffect(() => { persistedImportant = important }, [important]) useEffect(() => { persistedOther = other }, [other]) @@ -1201,12 +1220,26 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = )}
+ ) : emailConnected === false ? ( +
+ +

Connect your email to see your inbox here.

+ +
) : (
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
)} + ) } diff --git a/apps/x/apps/renderer/src/components/home-view.tsx b/apps/x/apps/renderer/src/components/home-view.tsx index 4534951c..21034f56 100644 --- a/apps/x/apps/renderer/src/components/home-view.tsx +++ b/apps/x/apps/renderer/src/components/home-view.tsx @@ -29,6 +29,7 @@ type HomeViewProps = { onVoiceNoteCreated?: (path: string) => void onRunBrowserTask?: () => void onStartResearch?: () => void + onOpenChat?: () => void } type CalEvent = { @@ -139,6 +140,22 @@ function noteLabel(node: TreeNode): string { return node.name } +function triggerMeetingCapture(event: CalEvent, openConference: boolean) { + window.__pendingCalendarEvent = { + summary: event.summary, + start: event.rawStart, + end: event.rawEnd, + location: event.location ?? undefined, + htmlLink: event.htmlLink ?? undefined, + conferenceLink: event.conferenceLink ?? undefined, + source: event.source, + } + if (openConference && event.conferenceLink) { + window.open(event.conferenceLink, '_blank') + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) +} + const CARD = 'rounded-xl border border-border bg-card p-4' export function HomeView({ @@ -155,6 +172,7 @@ export function HomeView({ onVoiceNoteCreated, onRunBrowserTask, onStartResearch, + onOpenChat, }: HomeViewProps) { const [events, setEvents] = useState([]) const [emails, setEmails] = useState([]) @@ -338,6 +356,7 @@ export function HomeView({ Background agents {activeAgents.length} active + {recentAgent ? ( + {e.conferenceLink && ( + + )} + ))} @@ -406,29 +445,26 @@ export function HomeView({ )} - {/* Discovery carousel */} - , - }, - { - icon: Globe, - title: 'Run a browser task', - desc: 'let the agent drive a real browser to look things up and get things done.', - onAction: onRunBrowserTask, - }, - { - icon: Telescope, - title: 'Do extreme research', - desc: 'Rowboat digs deep and builds you a local website with the findings.', - onAction: onStartResearch, - }, - ]} - /> + {/* Open chat CTA */} + {onOpenChat && ( + + )} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index 15f9f49b..a757a34c 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -119,7 +119,9 @@ export function KnowledgeView({ const rows = useMemo(() => { const out: FlatRow[] = [] - flatten(tree, expanded, 0, out) + // Meetings and Workspace have dedicated destinations, so hide them here. + const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace') + flatten(visible, expanded, 0, out) return out }, [tree, expanded]) @@ -154,7 +156,12 @@ export function KnowledgeView({ + + ) : loading && events.length === 0 ? (
@@ -335,6 +367,7 @@ function UpcomingEvents() { )} + ) } @@ -348,7 +381,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
@@ -376,7 +409,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool {weekday}
-
+
{day.events.length === 0 ? (
)\]]+/i + +function findMeetingUrl(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const match = MEETING_URL_RE.exec(value) + // Calendar descriptions are often HTML, so decode & back to & in the URL. + return match ? match[0].replace(/&/g, '&') : undefined +} + /** * Extract a video conference link from raw Google Calendar event JSON. - * Checks conferenceData.entryPoints (video type), hangoutLink, then falls back - * to a top-level conferenceLink if present. + * Checks conferenceData.entryPoints (video type), hangoutLink, a top-level + * conferenceLink, then falls back to scanning the location/description for a + * known meeting URL (Zoom, Microsoft Teams, Google Meet). */ export function extractConferenceLink(raw: Record): string | undefined { const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined @@ -11,5 +27,5 @@ export function extractConferenceLink(raw: Record): string | un } if (typeof raw.hangoutLink === 'string') return raw.hangoutLink if (typeof raw.conferenceLink === 'string') return raw.conferenceLink - return undefined + return findMeetingUrl(raw.location) ?? findMeetingUrl(raw.description) }