mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
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:
parent
fbd0791d0c
commit
25a1976394
8 changed files with 268 additions and 117 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 & 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, 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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue