chat side pane, home/chat refinements, and connect prompts

Add the chat side-pane navigation with dock/close/open controls, refine
the Home and chat panes, show connect-account prompts in the Email and
Meetings views (Zoom/Teams/Meet), fix the dock-to-side arrow, and default
the app to Home with the chat docked on the right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-05-22 15:47:35 +05:30 committed by arkml
parent fbd0791d0c
commit 25a1976394
8 changed files with 268 additions and 117 deletions

View file

@ -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<string | null>(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<string | null>(null)
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
const [expandedFrom, setExpandedFrom] = useState<{
@ -1098,8 +1099,8 @@ function App() {
}, [processingRunIds])
// File tab state
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const [fileTabs, setFileTabs] = useState<FileTab[]>([{ id: 'home-tab', path: HOME_TAB_PATH }])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>('home-tab')
const activeFileTabIdRef = useRef(activeFileTabId)
activeFileTabIdRef.current = activeFileTabId
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
@ -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<string> => {
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() {
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCloseFullScreenChat}
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="Restore two-pane view"
>
<Minimize2 className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleKnowledgePane}
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 -mr-1 self-center shrink-0"
aria-label={isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
>
{isChatSidebarOpen ? <Maximize2 className="size-5" /> : <Minimize2 className="size-5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
</TooltipContent>
</Tooltip>
)}
{/* 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: <ArrowRight className="size-5" />, label: 'Dock chat to side pane' }
: (viewOpen && !isChatSidebarOpen)
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
: null
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={action ? action.onClick : undefined}
disabled={!action}
aria-hidden={!action}
aria-label={action?.label}
className={cn(
'titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors -mr-1 self-center shrink-0',
action ? 'hover:bg-accent hover:text-foreground' : 'invisible pointer-events-none',
)}
>
{action?.icon}
</button>
</TooltipTrigger>
{action && <TooltipContent side="bottom">{action.label}</TooltipContent>}
</Tooltip>
)
})()}
</ContentHeader>
{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}
/>
</div>
) : isSuggestedTopicsOpen ? (
@ -5573,24 +5597,7 @@ 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">
{(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) => {
{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() {
</Conversation>
</div>
)
})
)}
})}
</div>
<div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
@ -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}

View file

@ -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' },
]
/**

View file

@ -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<string, ChatTabViewState>
@ -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 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
>
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
</TooltipContent>
</Tooltip>
{isMaximized ? (
onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Dock chat to side pane"
>
<ArrowRight className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
</Tooltip>
)
) : (
onCloseChat && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onCloseChat}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close chat"
>
<X className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close chat</TooltipContent>
</Tooltip>
)
)}
</header>

View file

@ -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<string | null>(null)
const [query, setQuery] = useState('')
// Gmail connection status — null while checking, false shows the connect prompt.
const [emailConnected, setEmailConnected] = useState<boolean | null>(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 =
</section>
)}
</div>
) : emailConnected === false ? (
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
<Mail size={28} className="opacity-50" />
<p>Connect your email to see your inbox here.</p>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Mail size={15} />
Connect your email
</button>
</div>
) : (
<div className="gmail-empty-state">
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
</div>
)}
</div>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</div>
)
}

View file

@ -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<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
@ -338,6 +356,7 @@ export function HomeView({
<span className="text-sm font-medium">Background agents</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
<button type="button" onClick={onOpenAgents} className="text-xs text-primary hover:underline">Open </button>
</div>
{recentAgent ? (
<button
@ -374,12 +393,32 @@ export function HomeView({
{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' : ''}`}>
<div key={e.id} className={`group 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>
<span className="min-w-0 flex-1 truncate font-medium">{e.summary}</span>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => triggerMeetingCapture(e, false)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Mic className="size-3" />
Take notes
</button>
{e.conferenceLink && (
<button
type="button"
onClick={() => triggerMeetingCapture(e, true)}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent"
>
<Video className="size-3" />
Join &amp; take notes
</button>
)}
</div>
</div>
))}
</div>
@ -406,29 +445,26 @@ export function HomeView({
</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,
},
]}
/>
{/* Open chat CTA */}
{onOpenChat && (
<button
type="button"
onClick={onOpenChat}
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground">
<MessageSquare className="size-[15px]" />
</div>
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
<span className="font-medium">Ask anything</span>
<span className="text-muted-foreground"> create presentations, do research, collaborate on docs.</span>
</div>
<span className="flex shrink-0 items-center gap-1 text-[12.5px] font-medium text-primary">
New chat
<ArrowRight className="size-3.5" />
</span>
</button>
)}
</div>
</div>

View file

@ -119,7 +119,9 @@ export function KnowledgeView({
const rows = useMemo<FlatRow[]>(() => {
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({
</button>
<button
type="button"
onClick={() => void actions.createFolder()}
onClick={async () => {
try {
const path = await actions.createFolder()
setRenameTarget(path)
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderPlus className="size-4" />
@ -359,7 +366,7 @@ function KnowledgeRow({
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(node.path)}>

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SettingsDialog } from '@/components/settings-dialog'
import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event'
import { cn } from '@/lib/utils'
@ -189,6 +190,24 @@ function UpcomingEvents() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// Calendar connection status — null while checking, false shows the connect prompt.
const [calendarConnected, setCalendarConnected] = useState<boolean | null>(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: 'googlecalendar' })
if (!cancelled) setCalendarConnected(result.isConnected)
} catch {
if (!cancelled) setCalendarConnected(false)
}
}
void check()
const cleanup = window.ipc.on('oauth:didConnect', () => { void check() })
return () => { cancelled = true; cleanup() }
}, [])
const loadEvents = useCallback(async () => {
setLoading(true)
@ -313,7 +332,20 @@ function UpcomingEvents() {
)}
</div>
{loading && events.length === 0 ? (
{calendarConnected === false && events.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<Calendar className="size-7 text-muted-foreground opacity-50" />
<p className="text-sm text-muted-foreground">Connect your calendar to see upcoming meetings here.</p>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Calendar className="size-4" />
Connect your calendar
</button>
</div>
) : loading && events.length === 0 ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
@ -335,6 +367,7 @@ function UpcomingEvents() {
</div>
)}
</div>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
</section>
)
}
@ -348,7 +381,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
<div
className="grid"
style={{
gridTemplateColumns: '96px 1fr',
gridTemplateColumns: '96px minmax(0, 1fr)',
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
}}
>
@ -376,7 +409,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
</span>
</div>
<div className="flex flex-col py-3 pr-3">
<div className="flex min-w-0 flex-col py-3 pr-3">
{day.events.length === 0 ? (
<div
className="flex w-full items-center gap-3 px-3 py-2 text-sm"

View file

@ -1,7 +1,23 @@
/**
* Matches a video-conference join URL for the providers we support (Zoom,
* Microsoft Teams, Google Meet). Captures the full URL up to the first
* whitespace, quote, or angle/round/square bracket.
*/
const MEETING_URL_RE =
/https?:\/\/(?:[a-z0-9-]+\.)*(?:zoom\.us|zoomgov\.com|teams\.microsoft\.com|teams\.live\.com|meet\.google\.com)\/[^\s"'<>)\]]+/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 &amp; back to & in the URL.
return match ? match[0].replace(/&amp;/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, unknown>): string | undefined {
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: string }[] } | undefined
@ -11,5 +27,5 @@ export function extractConferenceLink(raw: Record<string, unknown>): 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)
}