move tasks, meetings, and email into the sidebar; add chat history

Surface background tasks, upcoming meetings (with a live recording
indicator), and important unread email directly in the sidebar; add a
chat history page with chat icons and a home action row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-05-22 15:46:53 +05:30 committed by arkml
parent 346c685ac9
commit 193c2a9131
6 changed files with 1128 additions and 316 deletions

View file

@ -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, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, 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';
@ -27,6 +27,7 @@ import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view'; import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view'; import { WorkspaceView } from '@/components/workspace-view';
import { KnowledgeView } from '@/components/knowledge-view'; import { KnowledgeView } from '@/components/knowledge-view';
import { ChatHistoryView } from '@/components/chat-history-view';
import { MeetingsView } from '@/components/meetings-view'; import { MeetingsView } from '@/components/meetings-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
@ -189,6 +190,7 @@ const EMAIL_TAB_PATH = '__rowboat_email__'
const WORKSPACE_TAB_PATH = '__rowboat_workspace__' const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
const WORKSPACE_ROOT = 'knowledge/Workspace' const WORKSPACE_ROOT = 'knowledge/Workspace'
const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) => const clampNumber = (value: number, min: number, max: number) =>
@ -324,6 +326,7 @@ const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => { const getSuggestedTopicTargetFolder = (category?: string) => {
@ -576,6 +579,7 @@ type ViewState =
| { type: 'email' } | { type: 'email' }
| { type: 'workspace'; path?: string } | { type: 'workspace'; path?: string }
| { type: 'knowledge-view' } | { type: 'knowledge-view' }
| { type: 'chat-history' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean { function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false if (a.type !== b.type) return false
@ -632,6 +636,8 @@ function parseDeepLink(input: string): ViewState | null {
} }
case 'knowledge-view': case 'knowledge-view':
return { type: 'knowledge-view' } return { type: 'knowledge-view' }
case 'chat-history':
return { type: 'chat-history' }
default: default:
return null return null
} }
@ -640,12 +646,8 @@ function parseDeepLink(input: string): ViewState | null {
/** Sidebar toggle (fixed position, top-left) */ /** Sidebar toggle (fixed position, top-left) */
function FixedSidebarToggle({ function FixedSidebarToggle({
leftInsetPx, leftInsetPx,
onNewChat,
onOpenSearch,
}: { }: {
leftInsetPx: number leftInsetPx: number
onNewChat?: () => void
onOpenSearch?: () => void
}) { }) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar()
return ( return (
@ -661,28 +663,6 @@ function FixedSidebarToggle({
> >
<PanelLeftIcon className="size-5" /> <PanelLeftIcon className="size-5" />
</button> </button>
{onNewChat && (
<button
type="button"
onClick={onNewChat}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="New chat"
title="New chat"
>
<SquarePen className="size-5" />
</button>
)}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Search"
title="Search"
>
<SearchIcon className="size-5" />
</button>
)}
</div> </div>
) )
} }
@ -770,6 +750,9 @@ function App() {
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
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 [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
const [expandedFrom, setExpandedFrom] = useState<{ const [expandedFrom, setExpandedFrom] = useState<{
path: string | null path: string | null
graph: boolean graph: boolean
@ -1125,7 +1108,8 @@ function App() {
if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isBgTasksTabPath(tab.path)) return 'Background tasks'
if (isEmailTabPath(tab.path)) return 'Email' if (isEmailTabPath(tab.path)) return 'Email'
if (isWorkspaceTabPath(tab.path)) return 'Workspace' if (isWorkspaceTabPath(tab.path)) return 'Workspace'
if (isKnowledgeViewTabPath(tab.path)) return 'Knowledge' if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -1414,6 +1398,13 @@ function App() {
loadBackgroundTasks() loadBackgroundTasks()
} }
// Reload bg-task summaries if anything under bg-tasks/ changed
if (
eventPaths.some((p) => p === 'bg-tasks' || p.startsWith('bg-tasks/'))
) {
loadBgTaskSummaries()
}
// Invalidate cached content for files changed outside the active editor. // Invalidate cached content for files changed outside the active editor.
// This prevents stale backlinks after rename-rewrite passes touch many files. // This prevents stale backlinks after rename-rewrite passes touch many files.
for (const path of eventPaths) { for (const path of eventPaths) {
@ -1759,6 +1750,37 @@ function App() {
loadRuns() loadRuns()
}, [loadRuns]) }, [loadRuns])
const [bgTaskSummaries, setBgTaskSummaries] = useState<Array<{
slug: string
name: string
active: boolean
createdAt: string
lastAttemptAt?: string
lastRunAt?: string
}>>([])
const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState<string | null>(null)
const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0)
const loadBgTaskSummaries = useCallback(async () => {
try {
const result = await window.ipc.invoke('bg-task:list', { limit: 200 })
setBgTaskSummaries(result.items.map((it) => ({
slug: it.slug,
name: it.name,
active: it.active,
createdAt: it.createdAt,
lastAttemptAt: it.lastAttemptAt,
lastRunAt: it.lastRunAt,
})))
} catch (err) {
console.error('Failed to load bg-task summaries:', err)
}
}, [])
useEffect(() => {
loadBgTaskSummaries()
}, [loadBgTaskSummaries])
// Load background tasks // Load background tasks
const loadBackgroundTasks = useCallback(async () => { const loadBackgroundTasks = useCallback(async () => {
try { try {
@ -2840,7 +2862,7 @@ function App() {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
@ -2849,7 +2871,7 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay]) }, [fileTabs, dismissBrowserOverlay])
@ -2868,14 +2890,14 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
return return
} }
if (isSuggestedTopicsTabPath(tab.path)) { if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
return return
} }
if (isLiveNotesTabPath(tab.path)) { if (isLiveNotesTabPath(tab.path)) {
@ -2887,6 +2909,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
return return
} }
@ -2899,6 +2922,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsBgTasksOpen(true) setIsBgTasksOpen(true)
return return
} }
@ -2912,6 +2936,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
return return
} }
if (isEmailTabPath(tab.path)) { if (isEmailTabPath(tab.path)) {
@ -2923,6 +2948,7 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsEmailOpen(true) setIsEmailOpen(true)
return return
} }
@ -2935,6 +2961,7 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsWorkspaceOpen(true) setIsWorkspaceOpen(true)
return return
} }
@ -2947,18 +2974,32 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsChatHistoryOpen(false)
setIsKnowledgeViewOpen(true) setIsKnowledgeViewOpen(true)
return return
} }
if (isChatHistoryTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true)
return
}
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => { const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId) const closingTab = fileTabs.find(t => t.id === tabId)
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -2981,7 +3022,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
@ -2995,12 +3036,12 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) { } else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
} else if (isMeetingsTabPath(newActiveTab.path)) { } else if (isMeetingsTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3011,6 +3052,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) { } else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3020,6 +3062,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (isBgTasksTabPath(newActiveTab.path)) { } else if (isBgTasksTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
@ -3031,6 +3074,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
} else if (isEmailTabPath(newActiveTab.path)) { } else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3040,6 +3084,7 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsEmailOpen(true) setIsEmailOpen(true)
} else if (isWorkspaceTabPath(newActiveTab.path)) { } else if (isWorkspaceTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
@ -3050,6 +3095,7 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsWorkspaceOpen(true) setIsWorkspaceOpen(true)
} else if (isKnowledgeViewTabPath(newActiveTab.path)) { } else if (isKnowledgeViewTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
@ -3060,11 +3106,23 @@ function App() {
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsChatHistoryOpen(false)
setIsKnowledgeViewOpen(true) setIsKnowledgeViewOpen(true)
} else if (isChatHistoryTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -3095,7 +3153,7 @@ function App() {
dismissBrowserOverlay() dismissBrowserOverlay()
handleNewChat() handleNewChat()
// Left-pane "new chat" should always open full chat view. // Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
@ -3112,8 +3170,8 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen]) }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context. // Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => { const handleNewChatTabInSidebar = useCallback(() => {
@ -3245,7 +3303,7 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => { const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return // Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
@ -3261,19 +3319,19 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, dismissBrowserOverlay]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => { const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) { if (expandedFrom) {
if (expandedFrom.graph) { if (expandedFrom.graph) {
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
} else if (expandedFrom.suggestedTopics) { } else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
} else if (expandedFrom.meetings) { } else if (expandedFrom.meetings) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
@ -3305,7 +3363,7 @@ function App() {
} else if (expandedFrom.path) { } else if (expandedFrom.path) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedPath(expandedFrom.path) setSelectedPath(expandedFrom.path)
} }
setExpandedFrom(null) setExpandedFrom(null)
@ -3321,10 +3379,11 @@ function App() {
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
if (isKnowledgeViewOpen) return { type: 'knowledge-view' } if (isKnowledgeViewOpen) return { type: 'knowledge-view' }
if (isChatHistoryOpen) return { type: 'chat-history' }
if (selectedPath) return { type: 'file', path: selectedPath } if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' } if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId } return { type: 'chat', runId }
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, workspaceInitialPath, runId]) }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, workspaceInitialPath, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1] const last = stack[stack.length - 1]
@ -3447,6 +3506,17 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureChatHistoryFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isChatHistoryTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: CHAT_HISTORY_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const openEmailView = useCallback(() => { const openEmailView = useCallback(() => {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3469,7 +3539,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
@ -3503,7 +3573,7 @@ function App() {
// visible in the middle pane. // visible in the middle pane.
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files. // Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file. // Only exit chat-only maximize, because that would hide the selected file.
@ -3518,7 +3588,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -3531,7 +3601,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
@ -3544,7 +3614,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
ensureSuggestedTopicsFileTab() ensureSuggestedTopicsFileTab()
return return
case 'meetings': case 'meetings':
@ -3561,6 +3631,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
ensureMeetingsFileTab() ensureMeetingsFileTab()
return return
case 'live-notes': case 'live-notes':
@ -3576,6 +3647,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
ensureLiveNotesFileTab() ensureLiveNotesFileTab()
return return
@ -3593,6 +3665,7 @@ function App() {
setIsEmailOpen(true) setIsEmailOpen(true)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
ensureEmailFileTab() ensureEmailFileTab()
return return
case 'workspace': case 'workspace':
@ -3609,6 +3682,7 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(true) setIsWorkspaceOpen(true)
setIsKnowledgeViewOpen(false) setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(false)
setWorkspaceInitialPath(view.path ?? null) setWorkspaceInitialPath(view.path ?? null)
ensureWorkspaceFileTab() ensureWorkspaceFileTab()
return return
@ -3626,8 +3700,26 @@ function App() {
setIsEmailOpen(false) setIsEmailOpen(false)
setIsWorkspaceOpen(false) setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(true) setIsKnowledgeViewOpen(true)
setIsChatHistoryOpen(false)
ensureKnowledgeViewFileTab() ensureKnowledgeViewFileTab()
return return
case 'chat-history':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(false)
setIsChatHistoryOpen(true)
ensureChatHistoryFileTab()
return
case 'chat': case 'chat':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3636,7 +3728,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -3644,7 +3736,7 @@ function App() {
} }
return return
} }
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -3966,7 +4058,7 @@ function App() {
}, []) }, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view // Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask && !isBrowserOpen const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') { if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -4051,11 +4143,11 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => { const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey const mod = e.metaKey || e.ctrlKey
if (!mod) return if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && isChatSidebarOpen) const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane) ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left' : 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen)
const selectedKnowledgePath = isGraphOpen const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH ? GRAPH_TAB_PATH
: isSuggestedTopicsOpen : isSuggestedTopicsOpen
@ -4072,6 +4164,8 @@ function App() {
? WORKSPACE_TAB_PATH ? WORKSPACE_TAB_PATH
: isKnowledgeViewOpen : isKnowledgeViewOpen
? KNOWLEDGE_VIEW_TAB_PATH ? KNOWLEDGE_VIEW_TAB_PATH
: isChatHistoryOpen
? CHAT_HISTORY_TAB_PATH
: selectedPath : selectedPath
const targetFileTabId = activeFileTabId ?? ( const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath selectedKnowledgePath
@ -4126,7 +4220,7 @@ function App() {
} }
document.addEventListener('keydown', handleTabKeyDown) document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { if (kind === 'file') {
@ -4151,7 +4245,7 @@ function App() {
}), }),
}, },
})) }))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4277,28 +4371,28 @@ function App() {
}, },
openGraph: () => { openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view. // From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
openBases: () => { openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}, },
openWorkspaceAt: (path?: string) => { openWorkspaceAt: (path?: string) => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'workspace', path }) void navigateToView({ type: 'workspace', path })
}, },
openKnowledgeView: () => { openKnowledgeView: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4471,12 +4565,14 @@ function App() {
const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined) const pendingCalendarEventRef = useRef<CalendarEventMeta | undefined>(undefined)
const [meetingSummarizing, setMeetingSummarizing] = useState(false) const [meetingSummarizing, setMeetingSummarizing] = useState(false)
const [showMeetingPermissions, setShowMeetingPermissions] = useState(false) const [showMeetingPermissions, setShowMeetingPermissions] = useState(false)
const [recordingMeetingSource, setRecordingMeetingSource] = useState<string | null>(null)
const [checkingPermission, setCheckingPermission] = useState(false) const [checkingPermission, setCheckingPermission] = useState(false)
const startMeetingNow = useCallback(async () => { const startMeetingNow = useCallback(async () => {
const calEvent = pendingCalendarEventRef.current const calEvent = pendingCalendarEventRef.current
pendingCalendarEventRef.current = undefined pendingCalendarEventRef.current = undefined
setRecordingMeetingSource(calEvent?.source ?? null)
const notePath = await meetingTranscription.start(calEvent) const notePath = await meetingTranscription.start(calEvent)
if (notePath) { if (notePath) {
meetingNotePathRef.current = notePath meetingNotePathRef.current = notePath
@ -4504,6 +4600,7 @@ function App() {
const handleToggleMeeting = useCallback(async () => { const handleToggleMeeting = useCallback(async () => {
if (meetingTranscription.state === 'recording') { if (meetingTranscription.state === 'recording') {
await meetingTranscription.stop() await meetingTranscription.stop()
setRecordingMeetingSource(null)
// Read the final transcript and generate meeting notes via LLM // Read the final transcript and generate meeting notes via LLM
const notePath = meetingNotePathRef.current const notePath = meetingNotePathRef.current
@ -4913,7 +5010,7 @@ function App() {
const selectedTask = selectedBackgroundTask const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask) ? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null : null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => { const openMarkdownTabs = React.useMemo(() => {
@ -4930,7 +5027,7 @@ function App() {
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => { <SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen) { if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
} }
}}> }}>
@ -4953,7 +5050,7 @@ function App() {
onNewChat: handleNewChatTab, onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive() cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
} }
@ -4964,7 +5061,7 @@ function App() {
return return
} }
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad) loadRun(runIdToLoad)
return return
@ -4988,14 +5085,14 @@ function App() {
} else { } else {
// Only one tab, reset it to new chat // Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }]) setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
handleNewChat() handleNewChat()
} else { } else {
void navigateToView({ type: 'chat', runId: null }) void navigateToView({ type: 'chat', runId: null })
} }
} }
} else if (runId === runIdToDelete) { } else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat() handleNewChat()
} else { } else {
@ -5010,20 +5107,30 @@ function App() {
onSelectBackgroundTask: (taskName) => { onSelectBackgroundTask: (taskName) => {
void navigateToView({ type: 'task', name: taskName }) void navigateToView({ type: 'task', name: taskName })
}, },
onOpenChatHistoryView: () => {
void navigateToView({ type: 'chat-history' })
},
}}
bgTaskSummaries={bgTaskSummaries}
onOpenBgTask={(slug) => {
setBgTaskInitialSlug(slug)
setBgTaskSlugVersion((v) => v + 1)
openBgTasksView()
}} }}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
isSearchOpen={isSearchOpen}
isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isMeetingsOpen={isMeetingsOpen}
onOpenMeetings={openMeetingsView} onOpenMeetings={openMeetingsView}
isBgTasksOpen={isBgTasksOpen} meetingRecordingState={meetingTranscription.state}
onOpenBgTasks={openBgTasksView} recordingMeetingSource={recordingMeetingSource}
isEmailOpen={isEmailOpen} onToggleMeetingRecording={() => { void handleToggleMeeting() }}
onOpenEmail={openEmailView} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
onOpenEmail={(threadId) => {
setEmailInitialThreadId(threadId ?? null)
setEmailThreadIdVersion((v) => v + 1)
openEmailView()
}}
onOpenHome={navigateToFullScreenChat}
onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
onToggleBrowser={handleToggleBrowser}
/> />
<SidebarInset <SidebarInset
className={cn( className={cn(
@ -5043,7 +5150,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -5051,7 +5158,7 @@ function App() {
getTabId={(t) => t.id} getTabId={(t) => t.id}
onSwitchTab={switchFileTab} onSwitchTab={switchFileTab}
onCloseTab={closeFileTab} onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/> />
) : ( ) : (
<TabBar <TabBar
@ -5104,7 +5211,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent> <TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !selectedTask && !isBrowserOpen && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -5119,7 +5226,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isBrowserOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -5134,7 +5241,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent> <TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen) && ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -5188,6 +5295,8 @@ function App() {
) : isBgTasksOpen ? ( ) : isBgTasksOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BgTasksView <BgTasksView
initialSlug={bgTaskInitialSlug}
slugVersion={bgTaskSlugVersion}
onCreateWithCopilot={(description) => { onCreateWithCopilot={(description) => {
submitFromPalette(buildBgTaskSetupPrompt(description), null) submitFromPalette(buildBgTaskSetupPrompt(description), null)
}} }}
@ -5198,7 +5307,7 @@ function App() {
</div> </div>
) : isEmailOpen ? ( ) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView /> <EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} />
</div> </div>
) : isWorkspaceOpen ? ( ) : isWorkspaceOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
@ -5229,6 +5338,26 @@ function App() {
onVoiceNoteCreated={handleVoiceNoteCreated} onVoiceNoteCreated={handleVoiceNoteCreated}
/> />
</div> </div>
) : isChatHistoryOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<ChatHistoryView
runs={runs}
currentRunId={runId}
processingRunIds={processingRunIds}
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onOpenInNewTab={(rid) => openChatInNewTab(rid)}
onDeleteRun={async (rid) => {
try {
await window.ipc.invoke('runs:delete', { runId: rid })
await loadRuns()
} catch (err) {
console.error('Failed to delete run:', err)
}
}}
onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
/>
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? ( ) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView <BasesView
@ -5651,8 +5780,6 @@ function App() {
{/* Rendered last so its no-drag region paints over the sidebar drag region */} {/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle <FixedSidebarToggle
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
/> />
</SidebarProvider> </SidebarProvider>
</div> </div>

View file

@ -1418,6 +1418,18 @@ export interface BgTasksViewProps {
* "Edit with Copilot" button in the detail-view sidebar footer. * "Edit with Copilot" button in the detail-view sidebar footer.
*/ */
onEditWithCopilot?: (slug: string) => void onEditWithCopilot?: (slug: string) => void
/**
* If provided, the view opens with this task already selected. Updates to
* this prop sync into internal state so the sidebar can swap which task is
* focused without remounting the view.
*/
initialSlug?: string | null
/**
* Bump this counter to force a re-focus on `initialSlug` even when the
* slug value itself didn't change (e.g. user clicks the same task in the
* sidebar twice after navigating away inside the view).
*/
slugVersion?: number
} }
function formatLastRanLabel(iso: string | null | undefined): string { function formatLastRanLabel(iso: string | null | undefined): string {
@ -1425,9 +1437,12 @@ function formatLastRanLabel(iso: string | null | undefined): string {
return formatRelativeTime(iso) || 'Never' return formatRelativeTime(iso) || 'Never'
} }
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) { export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) {
const [items, setItems] = useState<BackgroundTaskSummary[]>([]) const [items, setItems] = useState<BackgroundTaskSummary[]>([])
const [selectedSlug, setSelectedSlug] = useState<string | null>(null) const [selectedSlug, setSelectedSlug] = useState<string | null>(initialSlug ?? null)
useEffect(() => {
setSelectedSlug(initialSlug ?? null)
}, [initialSlug, slugVersion])
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 [showNewDialog, setShowNewDialog] = useState(false) const [showNewDialog, setShowNewDialog] = useState(false)

View file

@ -0,0 +1,177 @@
import { useCallback, useMemo, useState } from 'react'
import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatRelativeTime } from '@/lib/relative-time'
type Run = {
id: string
title?: string
createdAt: string
agentId: string
}
type ChatHistoryViewProps = {
runs: Run[]
currentRunId?: string | null
processingRunIds?: Set<string>
onSelectRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onDeleteRun: (runId: string) => Promise<void> | void
onNewChat?: () => void
onOpenSearch?: () => void
}
export function ChatHistoryView({
runs,
currentRunId,
processingRunIds,
onSelectRun,
onOpenInNewTab,
onDeleteRun,
onNewChat,
onOpenSearch,
}: ChatHistoryViewProps) {
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
const handleConfirmDelete = useCallback(async () => {
if (!pendingDeleteId) return
const id = pendingDeleteId
setPendingDeleteId(null)
await onDeleteRun(id)
}, [pendingDeleteId, onDeleteRun])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
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"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
</div>
{sortedRuns.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
) : (
sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className={[
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
isActive ? 'bg-accent/60' : '',
].join(' ')}
>
<div className="flex flex-1 items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})
)}
</div>
</div>
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => { if (!open) setPendingDeleteId(null) }}>
<DialogContent showCloseButton={false} className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
Are you sure you want to delete this chat?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -817,12 +817,28 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
return { ...state, loadingPage: false } return { ...state, loadingPage: false }
} }
export function EmailView() { export type EmailViewProps = {
/** If provided, the view opens with this thread already expanded. */
initialThreadId?: string | null
/** Bump to re-focus on the same threadId after navigating away inside the view. */
threadIdVersion?: number
}
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant)) const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther)) const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
const hadPersistedDataOnMount = useRef(persistedImportant !== null) const hadPersistedDataOnMount = useRef(persistedImportant !== null)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null) const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([]) const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(initialThreadId ? [initialThreadId] : [])
useEffect(() => {
setSelectedThreadId(initialThreadId ?? null)
if (initialThreadId) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== initialThreadId)
return [...without, initialThreadId].slice(-MAX_KEPT_OPEN)
})
}
}, [initialThreadId, threadIdVersion])
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('')

View file

@ -142,7 +142,7 @@ export function KnowledgeView({
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6"> <div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Knowledge</h1> <h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"

View file

@ -14,12 +14,16 @@ import {
Globe, Globe,
AlertTriangle, AlertTriangle,
HelpCircle, HelpCircle,
Home,
Mic, Mic,
SearchIcon,
SquarePen,
Plug, Plug,
Lightbulb, Plus,
ListChecks, Video,
LoaderIcon, LoaderIcon,
Mail, Mail,
MessageSquare,
Settings, Settings,
Square, Square,
Trash2, Trash2,
@ -80,6 +84,7 @@ import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast" import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time" import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling" import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js" import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod" import z from "zod"
@ -124,21 +129,13 @@ type RunListItem = {
agentId: string agentId: string
} }
type BackgroundTaskItem = { type TaskSummary = {
slug: string
name: string name: string
description?: string active: boolean
schedule: { createdAt: string
type: "cron" | "window" | "once" lastAttemptAt?: string
expression?: string lastRunAt?: string
cron?: string
startTime?: string
endTime?: string
runAt?: string
}
enabled: boolean
status?: "scheduled" | "running" | "finished" | "failed" | "triggered"
nextRunAt?: string | null
lastRunAt?: string | null
} }
type ServiceEventType = z.infer<typeof ServiceEvent> type ServiceEventType = z.infer<typeof ServiceEvent>
@ -183,6 +180,7 @@ type TasksActions = {
onDeleteRun: (runId: string) => void onDeleteRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void onSelectBackgroundTask?: (taskName: string) => void
onOpenChatHistoryView?: () => void
} }
type SidebarContentPanelProps = { type SidebarContentPanelProps = {
@ -194,19 +192,18 @@ type SidebarContentPanelProps = {
currentRunId?: string | null currentRunId?: string | null
processingRunIds?: Set<string> processingRunIds?: Set<string>
tasksActions?: TasksActions tasksActions?: TasksActions
backgroundTasks?: BackgroundTaskItem[] bgTaskSummaries?: TaskSummary[]
selectedBackgroundTask?: string | null onOpenBgTask?: (slug: string) => void
isSearchOpen?: boolean
isBrowserOpen?: boolean
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
isMeetingsOpen?: boolean
onOpenMeetings?: () => void onOpenMeetings?: () => void
isBgTasksOpen?: boolean meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
recordingMeetingSource?: string | null
onToggleMeetingRecording?: () => void
onOpenBgTasks?: () => void onOpenBgTasks?: () => void
isEmailOpen?: boolean onOpenEmail?: (threadId?: string) => void
onOpenEmail?: () => void onOpenHome?: () => void
onNewChat?: () => void
onOpenSearch?: () => void
onToggleBrowser?: () => void
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
function formatEventTime(ts: string): string { function formatEventTime(ts: string): string {
@ -443,19 +440,18 @@ export function SidebarContentPanel({
currentRunId, currentRunId,
processingRunIds, processingRunIds,
tasksActions, tasksActions,
backgroundTasks = [], bgTaskSummaries = [],
selectedBackgroundTask, onOpenBgTask,
isSearchOpen = false,
isBrowserOpen = false,
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
isMeetingsOpen = false,
onOpenMeetings, onOpenMeetings,
isBgTasksOpen = false, meetingRecordingState,
recordingMeetingSource,
onToggleMeetingRecording,
onOpenBgTasks, onOpenBgTasks,
isEmailOpen = false,
onOpenEmail, onOpenEmail,
onOpenHome,
onNewChat,
onOpenSearch,
onToggleBrowser,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const [hasOauthError, setHasOauthError] = useState(false) const [hasOauthError, setHasOauthError] = useState(false)
@ -467,11 +463,6 @@ export function SidebarContentPanel({
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null) const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected) const { billing } = useBilling(isRowboatConnected)
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => { const handleRowboatLogin = useCallback(async () => {
try { try {
@ -534,56 +525,70 @@ export function SidebarContentPanel({
<SidebarHeader className="titlebar-drag-region"> <SidebarHeader className="titlebar-drag-region">
{/* Top spacer to clear the traffic lights + fixed toggle row */} {/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" /> <div className="h-8" />
{/* Quick action buttons */} <div className="titlebar-no-drag flex items-center gap-1 px-2 pb-1">
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1"> {onOpenHome && (
{onOpenEmail && (
<button <button
type="button" type="button"
onClick={onOpenEmail} onClick={onOpenHome}
className={cn( className="flex size-8 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", aria-label="Home"
isEmailQuickActionSelected title="Home"
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
> >
<Mail className="size-4" /> <Home className="size-4" />
<span>Email</span>
</button> </button>
)} )}
{onOpenMeetings && ( {onNewChat && (
<button <button
type="button" type="button"
onClick={onOpenMeetings} onClick={onNewChat}
className={cn( className="flex size-8 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", aria-label="New chat"
isMeetingsQuickActionSelected title="New chat"
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
> >
<Mic className="size-4" /> <SquarePen className="size-4" />
<span>Meetings</span>
</button> </button>
)} )}
{onOpenBgTasks && ( {onToggleBrowser && (
<button <button
type="button" type="button"
onClick={onOpenBgTasks} onClick={onToggleBrowser}
className={cn( className="flex size-8 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors", aria-label="Run browser task"
isBgTasksQuickActionSelected title="Run browser task"
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
> >
<ListChecks className="size-4" /> <Globe className="size-4" />
<span>Agents</span> </button>
)}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="flex size-8 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
aria-label="Search"
title="Search"
>
<SearchIcon className="size-4" />
</button> </button>
)} )}
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<EmailSidebarSection
onOpenEmailView={onOpenEmail}
onOpenConnectors={() => setConnectorsOpen(true)}
/>
<MeetingsSidebarSection
onOpenMeetingsView={onOpenMeetings}
onOpenConnectors={() => setConnectorsOpen(true)}
recordingState={meetingRecordingState ?? 'idle'}
recordingSource={recordingMeetingSource ?? null}
onToggleRecording={onToggleMeetingRecording}
/>
<TasksSidebarSection
tasks={bgTaskSummaries}
onOpenTask={onOpenBgTask}
onOpenTasksView={onOpenBgTasks}
/>
<KnowledgeSection <KnowledgeSection
tree={tree} tree={tree}
selectedPath={selectedPath} selectedPath={selectedPath}
@ -596,8 +601,6 @@ export function SidebarContentPanel({
currentRunId={currentRunId} currentRunId={currentRunId}
processingRunIds={processingRunIds} processingRunIds={processingRunIds}
actions={tasksActions} actions={tasksActions}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/> />
</SidebarContent> </SidebarContent>
{/* Billing / upgrade CTA or Log in CTA */} {/* Billing / upgrade CTA or Log in CTA */}
@ -638,43 +641,6 @@ export function SidebarContentPanel({
</button> </button>
</div> </div>
)} )}
{/* Secondary quick actions (above bottom divider) */}
{(onToggleBrowser || onOpenSuggestedTopics) && (
<div className="px-2 pb-1">
<div className="flex flex-col gap-0.5">
{onToggleBrowser && (
<button
type="button"
onClick={onToggleBrowser}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors",
isBrowserQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Globe className="size-4" />
<span>Run browser task</span>
</button>
)}
{onOpenSuggestedTopics && (
<button
type="button"
onClick={onOpenSuggestedTopics}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors",
isSuggestedTopicsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Lightbulb className="size-4" />
<span>Suggested Topics</span>
</button>
)}
</div>
</div>
)}
{/* Bottom actions */} {/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2"> <div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -1040,7 +1006,7 @@ function KnowledgeSection({
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarGroup className="flex flex-col"> <SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Knowledge Notes
</div> </div>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
@ -1056,9 +1022,24 @@ function KnowledgeSection({
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton onClick={() => actions.openKnowledgeView()}> <SidebarMenuButton
onClick={() =>
recentNotes.length === 0
? actions.createNote()
: actions.openKnowledgeView()
}
>
{recentNotes.length === 0 ? (
<>
<Plus className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">New note</span>
</>
) : (
<>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" /> <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span> <span className="text-muted-foreground">View all</span>
</>
)}
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
@ -1112,8 +1093,17 @@ export function WorkspaceSection({
))} ))}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton onClick={() => actions.openWorkspaceAt()}> <SidebarMenuButton onClick={() => actions.openWorkspaceAt()}>
{recentWorkspaces.length === 0 ? (
<>
<Plus className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">New workspace</span>
</>
) : (
<>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" /> <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span> <span className="text-muted-foreground">View all</span>
</>
)}
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
@ -1123,25 +1113,540 @@ export function WorkspaceSection({
} }
// Get status indicator color type UpcomingMeeting = {
function getStatusColor(status?: string, enabled?: boolean): string { id: string
// Disabled agents always show gray summary: string
if (enabled === false) { start: Date
return "bg-gray-400" isAllDay: boolean
location: string | null
htmlLink: string | null
conferenceLink: string | null
source: string
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
} }
switch (status) {
case "running": type RawCalendarEvent = {
return "bg-blue-500" id?: string
case "finished": summary?: string
return "bg-green-500" start?: { dateTime?: string; date?: string }
case "failed": end?: { dateTime?: string; date?: string }
return "bg-red-500" location?: string
case "triggered": htmlLink?: string
return "bg-gray-400" status?: string
case "scheduled": attendees?: Array<{ self?: boolean; responseStatus?: string }>
default:
return "bg-yellow-500"
} }
function parseAllDayDate(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
function normalizeUpcomingMeeting(raw: RawCalendarEvent, sourcePath: string): UpcomingMeeting | null {
if (raw.status === 'cancelled') return null
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
if (declined) return null
const allDayStart = raw.start?.date
const timedStart = raw.start?.dateTime
const isAllDay = !timedStart && Boolean(allDayStart)
let start: Date | null = null
let end: Date | null = null
if (timedStart) {
start = new Date(timedStart)
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
} else if (allDayStart) {
start = parseAllDayDate(allDayStart)
end = raw.end?.date ? parseAllDayDate(raw.end.date) : null
}
if (!start || Number.isNaN(start.getTime())) return null
const now = new Date()
const effectiveEnd = end ?? (isAllDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : start)
if (effectiveEnd <= now) return null
const conferenceLink = extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null
return {
id: raw.id ?? sourcePath,
summary: raw.summary?.trim() || '(No title)',
start,
isAllDay,
location: raw.location?.trim() || null,
htmlLink: raw.htmlLink ?? null,
conferenceLink,
source: sourcePath,
rawStart: raw.start,
rawEnd: raw.end,
}
}
function triggerMeetingCapture(event: UpcomingMeeting, 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'))
}
function isSameLocalDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function formatMeetingTime(event: UpcomingMeeting): string {
if (event.isAllDay) return 'All day'
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
const time = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (isSameLocalDay(event.start, now)) return time
if (isSameLocalDay(event.start, tomorrow)) return `Tmrw ${time}`
return event.start.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
}
type SidebarEmailThread = {
threadId: string
subject: string
from: string
date: string
}
function formatEmailFrom(from: string): string {
const match = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
if (match) return match[1].trim()
return from
}
function formatEmailTime(value: string): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.round(diffMs / 60000)
if (diffMin < 1) return 'now'
if (diffMin < 60) return `${diffMin}m`
const sameDay = date.toDateString() === now.toDateString()
if (sameDay) return `${Math.round(diffMin / 60)}h`
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return 'Yest'
if (diffMs < 7 * 24 * 60 * 60 * 1000) return date.toLocaleDateString([], { weekday: 'short' })
if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: '2-digit' })
}
function EmailSidebarSection({
onOpenEmailView,
onOpenConnectors,
}: {
onOpenEmailView?: (threadId?: string) => void
onOpenConnectors?: () => void
}) {
const [threads, setThreads] = useState<SidebarEmailThread[]>([])
const [connected, setConnected] = useState<boolean | null>(null)
const refreshConnected = useCallback(async () => {
try {
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
setConnected(result.isConnected)
} catch {
setConnected(false)
}
}, [])
useEffect(() => {
void refreshConnected()
const cleanup = window.ipc.on('oauth:didConnect', () => { void refreshConnected() })
return cleanup
}, [refreshConnected])
const load = useCallback(async () => {
try {
const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 })
const unread = result.threads
.filter((t) => t.unread === true)
.slice(0, 3)
.map<SidebarEmailThread>((t) => ({
threadId: t.threadId,
subject: t.subject ?? '(No subject)',
from: t.from ?? '',
date: t.date ?? '',
}))
setThreads(unread)
} catch (err) {
console.error('Failed to load important emails:', err)
}
}, [])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => { timeout = null; void load() }, 500)
}
const matches = (p: string | undefined) =>
typeof p === 'string' && (p === 'gmail_sync' || p.startsWith('gmail_sync/'))
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (matches(event.path)) scheduleReload()
break
case 'moved':
if (matches(event.from) || matches(event.to)) scheduleReload()
break
case 'bulkChanged':
if (!event.paths || event.paths.some(matches)) scheduleReload()
break
}
})
return () => {
if (timeout) clearTimeout(timeout)
cleanup()
}
}, [load])
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Email
</div>
<SidebarGroupContent>
<SidebarMenu>
{threads.map((t) => (
<SidebarMenuItem key={t.threadId}>
<SidebarMenuButton onClick={() => onOpenEmailView?.(t.threadId)} className="gap-2">
<Mail className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm">
{formatEmailFrom(t.from)}
<span className="text-muted-foreground"> · {t.subject}</span>
</span>
{t.date && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatEmailTime(t.date)}
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
{connected === false && threads.length === 0 ? (
onOpenConnectors && (
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenConnectors}>
<Plug className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">Connect Email</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
) : (
onOpenEmailView && (
<SidebarMenuItem>
<SidebarMenuButton onClick={() => onOpenEmailView()}>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
function MeetingsSidebarSection({
onOpenMeetingsView,
onOpenConnectors,
recordingState,
recordingSource,
onToggleRecording,
}: {
onOpenMeetingsView?: () => void
onOpenConnectors?: () => void
recordingState: 'idle' | 'connecting' | 'recording' | 'stopping'
recordingSource: string | null
onToggleRecording?: () => void
}) {
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
const [calendarConnected, setCalendarConnected] = useState<boolean | null>(null)
const refreshCalendarConnected = useCallback(async () => {
try {
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
setCalendarConnected(result.isConnected)
} catch {
setCalendarConnected(false)
}
}, [])
useEffect(() => {
void refreshCalendarConnected()
const cleanup = window.ipc.on('oauth:didConnect', () => { void refreshCalendarConnected() })
return cleanup
}, [refreshCalendarConnected])
const load = useCallback(async () => {
try {
const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' })
if (!exists.exists) {
setMeetings([])
return
}
const entries = await window.ipc.invoke('workspace:readdir', {
path: 'calendar_sync',
opts: { recursive: false, includeHidden: false, includeStats: false },
})
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
const settled = await Promise.allSettled(
jsonEntries.map(async (entry): Promise<UpcomingMeeting | null> => {
const result = await window.ipc.invoke('workspace:readFile', {
path: entry.path,
encoding: 'utf8',
})
const raw = JSON.parse(result.data) as RawCalendarEvent
return normalizeUpcomingMeeting(raw, entry.path)
}),
)
const collected: UpcomingMeeting[] = []
for (const r of settled) {
if (r.status === 'fulfilled' && r.value) collected.push(r.value)
}
collected.sort((a, b) => {
if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1
return a.start.getTime() - b.start.getTime()
})
setMeetings(collected.slice(0, 3))
} catch (err) {
console.error('Failed to load upcoming meetings:', err)
}
}, [])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => { timeout = null; void load() }, 250)
}
const matches = (p: string | undefined) =>
typeof p === 'string' && (p === 'calendar_sync' || p.startsWith('calendar_sync/'))
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (matches(event.path)) scheduleReload()
break
case 'moved':
if (matches(event.from) || matches(event.to)) scheduleReload()
break
case 'bulkChanged':
if (!event.paths || event.paths.some(matches)) scheduleReload()
break
}
})
const tick = setInterval(() => void load(), 60 * 60 * 1000)
return () => {
if (timeout) clearTimeout(timeout)
clearInterval(tick)
cleanup()
}
}, [load])
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Meetings
</div>
<SidebarGroupContent>
<SidebarMenu>
{meetings.map((m) => {
const hasConference = Boolean(m.conferenceLink)
const isThisRecording = recordingSource === m.source && (recordingState === 'recording' || recordingState === 'connecting' || recordingState === 'stopping')
const isBusy = isThisRecording && (recordingState === 'connecting' || recordingState === 'stopping')
return (
<SidebarMenuItem key={m.id}>
<SidebarMenuButton onClick={onOpenMeetingsView} className="gap-2">
<Mic className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm">{m.summary}</span>
<span
className={`shrink-0 text-[10px] text-muted-foreground ${isThisRecording ? '' : 'group-hover/menu-item:hidden'}`}
>
{isThisRecording ? null : formatMeetingTime(m)}
</span>
</SidebarMenuButton>
{isThisRecording ? (
<div className="absolute top-1.5 right-1 flex items-center gap-1.5">
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Stop recording"
disabled={isBusy}
onClick={(e) => {
e.stopPropagation()
onToggleRecording?.()
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex aspect-square w-5 items-center justify-center rounded-md text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
{isBusy ? <LoaderIcon className="size-4 animate-spin" /> : <Square className="size-3.5 fill-current" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{recordingState === 'connecting' ? 'Starting…' : recordingState === 'stopping' ? 'Stopping…' : 'Stop recording'}
</TooltipContent>
</Tooltip>
</div>
) : (
<div className="absolute top-1.5 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Take notes"
onClick={(e) => {
e.stopPropagation()
triggerMeetingCapture(m, false)
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex aspect-square w-5 items-center justify-center rounded-md text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Mic className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Take notes</TooltipContent>
</Tooltip>
{hasConference && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Join & take notes"
onClick={(e) => {
e.stopPropagation()
triggerMeetingCapture(m, true)
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex aspect-square w-5 items-center justify-center rounded-md text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<Video className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Join & take notes</TooltipContent>
</Tooltip>
)}
</div>
)}
</SidebarMenuItem>
)
})}
{calendarConnected === false && meetings.length === 0 ? (
onOpenConnectors && (
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenConnectors}>
<Plug className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">Connect Calendar</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
) : (
onOpenMeetingsView && (
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenMeetingsView}>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
function TasksSidebarSection({
tasks,
onOpenTask,
onOpenTasksView,
}: {
tasks: TaskSummary[]
onOpenTask?: (slug: string) => void
onOpenTasksView?: () => void
}) {
const recentTasks = React.useMemo<TaskSummary[]>(() => {
const toTime = (s?: string | null): number => {
if (!s) return 0
const t = new Date(s).getTime()
return Number.isNaN(t) ? 0 : t
}
const activity = (t: TaskSummary): number =>
Math.max(toTime(t.lastRunAt), toTime(t.lastAttemptAt), toTime(t.createdAt))
return [...tasks]
.sort((a, b) => activity(b) - activity(a))
.slice(0, 3)
}, [tasks])
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Tasks
</div>
<SidebarGroupContent>
<SidebarMenu>
{recentTasks.map((task) => (
<SidebarMenuItem key={task.slug}>
<SidebarMenuButton
onClick={() => onOpenTask?.(task.slug)}
className="gap-2"
>
<Bot className="size-4 shrink-0" />
<span className={`truncate text-sm ${!task.active ? "text-muted-foreground" : ""}`}>
{task.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{onOpenTasksView && (
<SidebarMenuItem>
<SidebarMenuButton onClick={onOpenTasksView}>
{recentTasks.length === 0 ? (
<>
<Plus className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">New Task</span>
</>
) : (
<>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span>
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
} }
// Tasks Section // Tasks Section
@ -1150,57 +1655,22 @@ function TasksSection({
currentRunId, currentRunId,
processingRunIds, processingRunIds,
actions, actions,
backgroundTasks = [],
selectedBackgroundTask,
}: { }: {
runs: RunListItem[] runs: RunListItem[]
currentRunId?: string | null currentRunId?: string | null
processingRunIds?: Set<string> processingRunIds?: Set<string>
actions?: TasksActions actions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
}) { }) {
const [pendingDeleteRunId, setPendingDeleteRunId] = useState<string | null>(null) const [pendingDeleteRunId, setPendingDeleteRunId] = useState<string | null>(null)
return ( return (
<SidebarGroup className="flex flex-col"> <SidebarGroup className="flex flex-col">
<SidebarGroupContent> <SidebarGroupContent>
{/* Background Tasks Section */}
{backgroundTasks.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Background Tasks
</div>
<SidebarMenu>
{backgroundTasks.map((task) => (
<SidebarMenuItem key={task.name}>
<SidebarMenuButton
isActive={selectedBackgroundTask === task.name}
onClick={() => actions?.onSelectBackgroundTask?.(task.name)}
className="gap-2"
>
<div className="relative">
<Bot className="size-4 shrink-0" />
<span
className={`absolute -bottom-0.5 -right-0.5 size-2 rounded-full ${getStatusColor(task.status, task.enabled)} ${task.status === "running" && task.enabled ? "animate-pulse" : ""}`}
/>
</div>
<span className={`truncate text-sm ${!task.enabled ? "text-muted-foreground" : ""}`}>
{task.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</>
)}
{runs.length > 0 && (
<>
<div className="px-3 py-1.5 mt-4 text-xs font-medium text-muted-foreground">
Chat history Chat history
</div> </div>
<SidebarMenu> <SidebarMenu>
{runs.map((run) => ( {runs.slice(0, 3).map((run) => (
<ContextMenu key={run.id}> <ContextMenu key={run.id}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem className="group/chat-item"> <SidebarMenuItem className="group/chat-item">
@ -1215,6 +1685,7 @@ function TasksSection({
}} }}
> >
<div className="flex w-full items-center gap-2 min-w-0"> <div className="flex w-full items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span> <span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
{run.createdAt ? ( {run.createdAt ? (
<span className="shrink-0 text-[10px] text-muted-foreground"> <span className="shrink-0 text-[10px] text-muted-foreground">
@ -1247,9 +1718,15 @@ function TasksSection({
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
))} ))}
</SidebarMenu> {runs.length > 0 && actions?.onOpenChatHistoryView && (
</> <SidebarMenuItem>
<SidebarMenuButton onClick={() => actions.onOpenChatHistoryView?.()}>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)} )}
</SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}