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 type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
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 { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
|
|
@ -757,7 +757,8 @@ function App() {
|
||||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = 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 [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||||
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||||
const [expandedFrom, setExpandedFrom] = useState<{
|
const [expandedFrom, setExpandedFrom] = useState<{
|
||||||
|
|
@ -1098,8 +1099,8 @@ function App() {
|
||||||
}, [processingRunIds])
|
}, [processingRunIds])
|
||||||
|
|
||||||
// File tab state
|
// File tab state
|
||||||
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
|
const [fileTabs, setFileTabs] = useState<FileTab[]>([{ id: 'home-tab', path: HOME_TAB_PATH }])
|
||||||
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
|
const [activeFileTabId, setActiveFileTabId] = useState<string | null>('home-tab')
|
||||||
const activeFileTabIdRef = useRef(activeFileTabId)
|
const activeFileTabIdRef = useRef(activeFileTabId)
|
||||||
activeFileTabIdRef.current = activeFileTabId
|
activeFileTabIdRef.current = activeFileTabId
|
||||||
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
|
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
|
||||||
|
|
@ -3272,9 +3273,10 @@ function App() {
|
||||||
return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
||||||
}, [submitFromPalette])
|
}, [submitFromPalette])
|
||||||
|
|
||||||
const toggleKnowledgePane = useCallback(() => {
|
// Reveal the chat in the right side pane (from the middle-panel chat icon).
|
||||||
|
const openChatSidePane = useCallback(() => {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setIsChatSidebarOpen(prev => !prev)
|
setIsChatSidebarOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Browser is an overlay on the middle pane: opening it forces the chat
|
// Browser is an overlay on the middle pane: opening it forces the chat
|
||||||
|
|
@ -3810,6 +3812,18 @@ function App() {
|
||||||
await applyViewState(nextView)
|
await applyViewState(nextView)
|
||||||
}, [appendUnique, applyViewState, cancelRecordingIfActive, currentViewState, setHistory, isBrowserOpen, dismissBrowserOverlay])
|
}, [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 navigateBack = useCallback(async () => {
|
||||||
const { back, forward } = historyRef.current
|
const { back, forward } = historyRef.current
|
||||||
if (back.length === 0) return
|
if (back.length === 0) return
|
||||||
|
|
@ -4412,14 +4426,23 @@ function App() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createFolder: async (parentPath: string = 'knowledge'): Promise<string> => {
|
createFolder: async (parentPath: string = 'knowledge'): Promise<string> => {
|
||||||
const newPath = `${parentPath}/new-folder-${Date.now()}`
|
|
||||||
try {
|
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', {
|
await window.ipc.invoke('workspace:mkdir', {
|
||||||
path: newPath,
|
path: fullPath,
|
||||||
recursive: true
|
recursive: true
|
||||||
})
|
})
|
||||||
setExpandedPaths(prev => new Set([...prev, parentPath]))
|
setExpandedPaths(prev => new Set([...prev, parentPath]))
|
||||||
return newPath
|
return fullPath
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create folder:', err)
|
console.error('Failed to create folder:', err)
|
||||||
throw err
|
throw err
|
||||||
|
|
@ -5227,38 +5250,38 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
|
{/* Trailing layout control. Always mounted (just toggled invisible
|
||||||
<Tooltip>
|
when inactive) so its -webkit-app-region:no-drag rect is stable —
|
||||||
<TooltipTrigger asChild>
|
a freshly-mounted no-drag button inside the drag-region header
|
||||||
<button
|
otherwise has its first click swallowed by the window drag. */}
|
||||||
type="button"
|
{(() => {
|
||||||
onClick={handleCloseFullScreenChat}
|
const viewOpen = selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen
|
||||||
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"
|
const action = isFullScreenChat
|
||||||
aria-label="Restore two-pane view"
|
? { onClick: pushChatToSidePane, icon: <ArrowRight className="size-5" />, label: 'Dock chat to side pane' }
|
||||||
>
|
: (viewOpen && !isChatSidebarOpen)
|
||||||
<Minimize2 className="size-5" />
|
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
|
||||||
</button>
|
: null
|
||||||
</TooltipTrigger>
|
return (
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<Tooltip>
|
||||||
</Tooltip>
|
<TooltipTrigger asChild>
|
||||||
)}
|
<button
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && (
|
type="button"
|
||||||
<Tooltip>
|
onClick={action ? action.onClick : undefined}
|
||||||
<TooltipTrigger asChild>
|
disabled={!action}
|
||||||
<button
|
aria-hidden={!action}
|
||||||
type="button"
|
aria-label={action?.label}
|
||||||
onClick={toggleKnowledgePane}
|
className={cn(
|
||||||
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"
|
'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',
|
||||||
aria-label={isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
|
action ? 'hover:bg-accent hover:text-foreground' : 'invisible pointer-events-none',
|
||||||
>
|
)}
|
||||||
{isChatSidebarOpen ? <Maximize2 className="size-5" /> : <Minimize2 className="size-5" />}
|
>
|
||||||
</button>
|
{action?.icon}
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent side="bottom">
|
</TooltipTrigger>
|
||||||
{isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
|
{action && <TooltipContent side="bottom">{action.label}</TooltipContent>}
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
)
|
||||||
)}
|
})()}
|
||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{isBrowserOpen ? (
|
{isBrowserOpen ? (
|
||||||
|
|
@ -5282,6 +5305,7 @@ function App() {
|
||||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
onRunBrowserTask={handleToggleBrowser}
|
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)}
|
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>
|
</div>
|
||||||
) : isSuggestedTopicsOpen ? (
|
) : isSuggestedTopicsOpen ? (
|
||||||
|
|
@ -5573,24 +5597,7 @@ function App() {
|
||||||
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{(activeChatTabState.conversation.length === 0 && !activeChatTabState.currentAssistantMessage) ? (
|
{chatTabs.map((tab) => {
|
||||||
<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 isActive = tab.id === activeChatTabId
|
||||||
const tabState = getChatTabStateForRender(tab.id)
|
const tabState = getChatTabStateForRender(tab.id)
|
||||||
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
||||||
|
|
@ -5694,8 +5701,7 @@ function App() {
|
||||||
</Conversation>
|
</Conversation>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
<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' })}
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
onOpenFullScreen={toggleRightPaneMaximize}
|
onOpenFullScreen={toggleRightPaneMaximize}
|
||||||
|
onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }}
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
currentAssistantMessage={currentAssistantMessage}
|
currentAssistantMessage={currentAssistantMessage}
|
||||||
chatTabStates={chatViewStateByTab}
|
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 { cn } from '@/lib/utils'
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
|
|
@ -21,8 +21,8 @@ interface ChatEmptyStateProps {
|
||||||
|
|
||||||
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
|
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: 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: Bot, title: 'Set up a background agent', sub: 'that automates tasks', prompt: 'Set up a background agent that automates [task]' },
|
||||||
{ icon: NotebookPen, title: 'Take notes', sub: 'while I work', prompt: 'Take notes while I work' },
|
{ 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 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 { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -125,6 +125,7 @@ interface ChatSidebarProps {
|
||||||
onSelectRun?: (runId: string) => void
|
onSelectRun?: (runId: string) => void
|
||||||
onOpenChatHistory?: () => void
|
onOpenChatHistory?: () => void
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
|
onCloseChat?: () => void
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
chatTabStates?: Record<string, ChatTabViewState>
|
chatTabStates?: Record<string, ChatTabViewState>
|
||||||
|
|
@ -180,6 +181,7 @@ export function ChatSidebar({
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onOpenChatHistory,
|
onOpenChatHistory,
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
|
onCloseChat,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
chatTabStates = {},
|
chatTabStates = {},
|
||||||
|
|
@ -509,23 +511,40 @@ export function ChatSidebar({
|
||||||
onSelectRun={onSelectRun}
|
onSelectRun={onSelectRun}
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
{onOpenFullScreen && (
|
{isMaximized ? (
|
||||||
<Tooltip>
|
onOpenFullScreen && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={onOpenFullScreen}
|
size="icon"
|
||||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
onClick={onOpenFullScreen}
|
||||||
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
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"
|
||||||
{isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
>
|
||||||
</Button>
|
<ArrowRight className="size-5" />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="bottom">
|
</TooltipTrigger>
|
||||||
{isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
|
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
|
|
@ -8,6 +8,7 @@ import type { blocks } from '@x/shared'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { useTheme } from '@/contexts/theme-context'
|
import { useTheme } from '@/contexts/theme-context'
|
||||||
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
|
|
||||||
type GmailThread = blocks.GmailThread
|
type GmailThread = blocks.GmailThread
|
||||||
type GmailThreadMessage = blocks.GmailThreadMessage
|
type GmailThreadMessage = blocks.GmailThreadMessage
|
||||||
|
|
@ -842,6 +843,24 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
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(() => { persistedImportant = important }, [important])
|
||||||
useEffect(() => { persistedOther = other }, [other])
|
useEffect(() => { persistedOther = other }, [other])
|
||||||
|
|
@ -1201,12 +1220,26 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="gmail-empty-state">
|
||||||
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
{initialLoading ? 'Loading Gmail threads…' : 'No Gmail threads in your inbox cache yet.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type HomeViewProps = {
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
onRunBrowserTask?: () => void
|
onRunBrowserTask?: () => void
|
||||||
onStartResearch?: () => void
|
onStartResearch?: () => void
|
||||||
|
onOpenChat?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CalEvent = {
|
type CalEvent = {
|
||||||
|
|
@ -139,6 +140,22 @@ function noteLabel(node: TreeNode): string {
|
||||||
return node.name
|
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'
|
const CARD = 'rounded-xl border border-border bg-card p-4'
|
||||||
|
|
||||||
export function HomeView({
|
export function HomeView({
|
||||||
|
|
@ -155,6 +172,7 @@ export function HomeView({
|
||||||
onVoiceNoteCreated,
|
onVoiceNoteCreated,
|
||||||
onRunBrowserTask,
|
onRunBrowserTask,
|
||||||
onStartResearch,
|
onStartResearch,
|
||||||
|
onOpenChat,
|
||||||
}: HomeViewProps) {
|
}: HomeViewProps) {
|
||||||
const [events, setEvents] = useState<CalEvent[]>([])
|
const [events, setEvents] = useState<CalEvent[]>([])
|
||||||
const [emails, setEmails] = useState<EmailThread[]>([])
|
const [emails, setEmails] = useState<EmailThread[]>([])
|
||||||
|
|
@ -338,6 +356,7 @@ export function HomeView({
|
||||||
<span className="text-sm font-medium">Background agents</span>
|
<span className="text-sm font-medium">Background agents</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
|
<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>
|
</div>
|
||||||
{recentAgent ? (
|
{recentAgent ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -374,12 +393,32 @@ export function HomeView({
|
||||||
{todaysEvents.length === 0 ? (
|
{todaysEvents.length === 0 ? (
|
||||||
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
|
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
|
||||||
) : todaysEvents.map((e, i) => (
|
) : 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">
|
<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)}` : ''}`}
|
{e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` – ${timeOfDay(e.end)}` : ''}`}
|
||||||
</span>
|
</span>
|
||||||
<span className={`size-2 shrink-0 rounded-full ${i === 0 ? 'bg-emerald-500' : 'bg-border'}`} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -406,29 +445,26 @@ export function HomeView({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Discovery carousel */}
|
{/* Open chat CTA */}
|
||||||
<DiscoveryCarousel
|
{onOpenChat && (
|
||||||
tips={[
|
<button
|
||||||
{
|
type="button"
|
||||||
icon: Mic,
|
onClick={onOpenChat}
|
||||||
title: 'Try a voice note',
|
className="flex items-center gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent"
|
||||||
desc: 'capture a thought out loud; the agent transcribes it and files it into Knowledge.',
|
>
|
||||||
action: <VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />,
|
<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>
|
||||||
icon: Globe,
|
<div className="min-w-0 flex-1 text-[13.5px] leading-snug">
|
||||||
title: 'Run a browser task',
|
<span className="font-medium">Ask anything</span>
|
||||||
desc: 'let the agent drive a real browser to look things up and get things done.',
|
<span className="text-muted-foreground"> — create presentations, do research, collaborate on docs.</span>
|
||||||
onAction: onRunBrowserTask,
|
</div>
|
||||||
},
|
<span className="flex shrink-0 items-center gap-1 text-[12.5px] font-medium text-primary">
|
||||||
{
|
New chat
|
||||||
icon: Telescope,
|
<ArrowRight className="size-3.5" />
|
||||||
title: 'Do extreme research',
|
</span>
|
||||||
desc: 'Rowboat digs deep and builds you a local website with the findings.',
|
</button>
|
||||||
onAction: onStartResearch,
|
)}
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,9 @@ export function KnowledgeView({
|
||||||
|
|
||||||
const rows = useMemo<FlatRow[]>(() => {
|
const rows = useMemo<FlatRow[]>(() => {
|
||||||
const out: 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
|
return out
|
||||||
}, [tree, expanded])
|
}, [tree, expanded])
|
||||||
|
|
||||||
|
|
@ -154,7 +156,12 @@ export function KnowledgeView({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<FolderPlus className="size-4" />
|
||||||
|
|
@ -359,7 +366,7 @@ function KnowledgeRow({
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48">
|
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{isDir && (
|
{isDir && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem onClick={() => actions.createNote(node.path)}>
|
<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 { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -189,6 +190,24 @@ function UpcomingEvents() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
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 () => {
|
const loadEvents = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -313,7 +332,20 @@ function UpcomingEvents() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-center py-6">
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -335,6 +367,7 @@ function UpcomingEvents() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +381,7 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
|
||||||
<div
|
<div
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: '96px 1fr',
|
gridTemplateColumns: '96px minmax(0, 1fr)',
|
||||||
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
|
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 style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{day.events.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
|
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.
|
* Extract a video conference link from raw Google Calendar event JSON.
|
||||||
* Checks conferenceData.entryPoints (video type), hangoutLink, then falls back
|
* Checks conferenceData.entryPoints (video type), hangoutLink, a top-level
|
||||||
* to a top-level conferenceLink if present.
|
* 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 {
|
export function extractConferenceLink(raw: Record<string, unknown>): string | undefined {
|
||||||
const confData = raw.conferenceData as { entryPoints?: { entryPointType?: string; uri?: 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.hangoutLink === 'string') return raw.hangoutLink
|
||||||
if (typeof raw.conferenceLink === 'string') return raw.conferenceLink
|
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