meetings page (#558)

* move meetings to own page

* show calendar
This commit is contained in:
arkml 2026-05-18 22:06:04 +05:30 committed by GitHub
parent 7dcf8eea70
commit 6492cf65b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 984 additions and 100 deletions

View file

@ -254,6 +254,15 @@
box-shadow: none; box-shadow: none;
} }
.upcoming-event-row {
background-color: transparent;
transition: background-color 120ms ease;
}
.upcoming-event-row:hover {
background-color: var(--gm-bg-pill-hover);
}
.gmail-row-selected { .gmail-row-selected {
background: var(--gm-bg-row-selected); background: var(--gm-bg-row-selected);
box-shadow: inset 2px 0 0 var(--gm-accent); box-shadow: inset 2px 0 0 var(--gm-accent);

View file

@ -25,6 +25,7 @@ import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { LiveNotesView } from '@/components/live-notes-view'; import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view'; import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view'; import { EmailView } from '@/components/email-view';
import { MeetingsView } from '@/components/meetings-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
Conversation, Conversation,
@ -177,6 +178,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__' const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const MEETINGS_TAB_PATH = '__rowboat_meetings__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
const EMAIL_TAB_PATH = '__rowboat_email__' const EMAIL_TAB_PATH = '__rowboat_email__'
@ -309,6 +311,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH 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
@ -559,6 +562,7 @@ type ViewState =
| { type: 'graph' } | { type: 'graph' }
| { type: 'task'; name: string } | { type: 'task'; name: string }
| { type: 'suggested-topics' } | { type: 'suggested-topics' }
| { type: 'meetings' }
| { type: 'live-notes' } | { type: 'live-notes' }
| { type: 'email' } | { type: 'email' }
@ -574,12 +578,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target. * malformed or names an unknown target.
* *
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|live-notes>&... * Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|meetings|live-notes>&...
* file: ?type=file&path=knowledge/foo.md * file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional) * chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph * graph: ?type=graph
* task: ?type=task&name=daily-brief * task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics * suggested-topics: ?type=suggested-topics
* meetings: ?type=meetings
* live-notes: ?type=live-notes * live-notes: ?type=live-notes
*/ */
function parseDeepLink(input: string): ViewState | null { function parseDeepLink(input: string): ViewState | null {
@ -605,6 +610,8 @@ function parseDeepLink(input: string): ViewState | null {
} }
case 'suggested-topics': case 'suggested-topics':
return { type: 'suggested-topics' } return { type: 'suggested-topics' }
case 'meetings':
return { type: 'meetings' }
case 'live-notes': case 'live-notes':
return { type: 'live-notes' } return { type: 'live-notes' }
default: default:
@ -712,6 +719,7 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false) const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [isMeetingsOpen, setIsMeetingsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
const [isEmailOpen, setIsEmailOpen] = useState(false) const [isEmailOpen, setIsEmailOpen] = useState(false)
@ -719,7 +727,9 @@ function App() {
path: string | null path: string | null
graph: boolean graph: boolean
suggestedTopics: boolean suggestedTopics: boolean
meetings: boolean
liveNotes: boolean liveNotes: boolean
bgTasks: boolean
email: boolean email: boolean
} | null>(null) } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
@ -1045,6 +1055,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => { const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View' if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isMeetingsTabPath(tab.path)) return 'Meetings'
if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isLiveNotesTabPath(tab.path)) return 'Live notes'
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'
@ -2760,7 +2771,7 @@ function App() {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
@ -2769,7 +2780,7 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay]) }, [fileTabs, dismissBrowserOverlay])
@ -2788,25 +2799,44 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isSuggestedTopicsTabPath(tab.path)) { if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isLiveNotesTabPath(tab.path)) { if (isLiveNotesTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
return return
} }
if (isBgTasksTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
return
}
if (isMeetingsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
return
}
if (isEmailTabPath(tab.path)) { if (isEmailTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -2818,7 +2848,7 @@ function App() {
} }
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
@ -2847,7 +2877,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
@ -2861,30 +2891,48 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) { } else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isMeetingsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) { } else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (isBgTasksTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
setIsEmailOpen(false)
} else if (isEmailTabPath(newActiveTab.path)) { } else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false) setIsLiveNotesOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(true) setIsEmailOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -2915,12 +2963,14 @@ 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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
meetings: isMeetingsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
bgTasks: isBgTasksOpen,
email: isEmailOpen, email: isEmailOpen,
}) })
} else { } else {
@ -2930,8 +2980,8 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen]) }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
// 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(() => {
@ -3063,12 +3113,14 @@ 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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
meetings: isMeetingsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
bgTasks: isBgTasksOpen,
email: isEmailOpen, email: isEmailOpen,
}) })
} }
@ -3077,35 +3129,51 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, 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)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.suggestedTopics) { } else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.meetings) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
} else if (expandedFrom.liveNotes) { } else if (expandedFrom.liveNotes) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (expandedFrom.bgTasks) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
setIsEmailOpen(false)
} else if (expandedFrom.email) { } else if (expandedFrom.email) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false) setIsLiveNotesOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(true) setIsEmailOpen(true)
} else if (expandedFrom.path) { } else if (expandedFrom.path) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(expandedFrom.path) setSelectedPath(expandedFrom.path)
} }
setExpandedFrom(null) setExpandedFrom(null)
@ -3116,12 +3184,13 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => { const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isEmailOpen) return { type: 'email' } if (isEmailOpen) return { type: 'email' }
if (isMeetingsOpen) return { type: 'meetings' }
if (isLiveNotesOpen) return { type: 'live-notes' } if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
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, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, 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]
@ -3189,6 +3258,17 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureMeetingsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isMeetingsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: MEETINGS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const ensureBgTasksFileTab = useCallback(() => { const ensureBgTasksFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path)) const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path))
if (existing) { if (existing) {
@ -3216,6 +3296,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false) setIsLiveNotesOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
@ -3230,7 +3311,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
@ -3238,6 +3319,21 @@ function App() {
ensureBgTasksFileTab() ensureBgTasksFileTab()
}, [ensureBgTasksFileTab]) }, [ensureBgTasksFileTab])
const openMeetingsView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
ensureMeetingsFileTab()
}, [ensureMeetingsFileTab])
const applyViewState = useCallback(async (view: ViewState) => { const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) { switch (view.type) {
case 'file': case 'file':
@ -3247,7 +3343,7 @@ function App() {
// visible in the middle pane. // visible in the middle pane.
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(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.
@ -3262,7 +3358,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -3275,7 +3371,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
@ -3288,9 +3384,23 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
ensureSuggestedTopicsFileTab() ensureSuggestedTopicsFileTab()
return return
case 'meetings':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
ensureMeetingsFileTab()
return
case 'live-notes': case 'live-notes':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3299,6 +3409,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(false) setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
@ -3312,6 +3423,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false) setIsLiveNotesOpen(false)
setIsBgTasksOpen(false) setIsBgTasksOpen(false)
setIsEmailOpen(true) setIsEmailOpen(true)
@ -3325,7 +3437,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -3333,7 +3445,7 @@ function App() {
} }
return return
} }
}, [ensureEmailFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -3655,7 +3767,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 && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !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') {
@ -3728,15 +3840,17 @@ 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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen) const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && 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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen)
const selectedKnowledgePath = isGraphOpen const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH ? GRAPH_TAB_PATH
: isSuggestedTopicsOpen : isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH ? SUGGESTED_TOPICS_TAB_PATH
: isMeetingsOpen
? MEETINGS_TAB_PATH
: isLiveNotesOpen : isLiveNotesOpen
? LIVE_NOTES_TAB_PATH ? LIVE_NOTES_TAB_PATH
: isBgTasksOpen : isBgTasksOpen
@ -3797,7 +3911,7 @@ function App() {
} }
document.addEventListener('keydown', handleTabKeyDown) document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, 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') {
@ -3822,7 +3936,7 @@ function App() {
}), }),
}, },
})) }))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -3948,14 +4062,14 @@ 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 && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
openBases: () => { openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4551,7 +4665,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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || 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(() => {
@ -4568,7 +4682,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 && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) { if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
} }
}}> }}>
@ -4601,7 +4715,7 @@ function App() {
onNewChat: handleNewChatTab, onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive() cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
} }
@ -4612,7 +4726,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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || 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
@ -4636,14 +4750,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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || 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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || 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 {
@ -4663,16 +4777,13 @@ function App() {
selectedBackgroundTask={selectedBackgroundTask} selectedBackgroundTask={selectedBackgroundTask}
onNewChat={handleNewChatTab} onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)} onOpenSearch={() => setIsSearchOpen(true)}
meetingState={meetingTranscription.state}
meetingSummarizing={meetingSummarizing}
meetingAvailable={voiceAvailable}
onToggleMeeting={() => { void handleToggleMeeting() }}
isSearchOpen={isSearchOpen} isSearchOpen={isSearchOpen}
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
isBrowserOpen={isBrowserOpen} isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser} onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen} isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isMeetingsOpen={isMeetingsOpen}
onOpenMeetings={openMeetingsView}
isLiveNotesOpen={isLiveNotesOpen} isLiveNotesOpen={isLiveNotesOpen}
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
isBgTasksOpen={isBgTasksOpen} isBgTasksOpen={isBgTasksOpen}
@ -4698,7 +4809,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -4706,7 +4817,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 || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/> />
) : ( ) : (
<TabBar <TabBar
@ -4759,7 +4870,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent> <TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4774,7 +4885,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4789,7 +4900,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent> <TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4822,6 +4933,15 @@ function App() {
}} }}
/> />
</div> </div>
) : isMeetingsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<MeetingsView
onOpenNote={(path) => navigateToFile(path)}
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
meetingState={meetingTranscription.state}
meetingSummarizing={meetingSummarizing}
/>
</div>
) : isLiveNotesOpen ? ( ) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<LiveNotesView <LiveNotesView

View file

@ -0,0 +1,778 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event'
import { cn } from '@/lib/utils'
import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
const MEETINGS_ROOT = 'knowledge/Meetings'
const CALENDAR_DIR = 'calendar_sync'
const UPCOMING_MAX_DAYS = 4 // today + next 3
type MeetingNoteRow = {
path: string
name: string
dateLabel: string
mtimeMs: number
}
type MeetingsViewProps = {
onOpenNote: (path: string) => void
onTakeMeetingNotes: () => void
meetingState: MeetingTranscriptionState
meetingSummarizing?: boolean
}
function isMeetingPath(path: string | undefined): boolean {
return typeof path === 'string' && (path === MEETINGS_ROOT || path.startsWith(`${MEETINGS_ROOT}/`))
}
function isCalendarPath(path: string | undefined): boolean {
return typeof path === 'string' && (path === CALENDAR_DIR || path.startsWith(`${CALENDAR_DIR}/`))
}
type RawCalendarEvent = {
id?: string
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
status?: string
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
hangoutLink?: string
conferenceLink?: string
}
type UpcomingEvent = {
id: string
summary: string
start: Date
end: Date | null
isAllDay: boolean
location: string | null
htmlLink: string | null
conferenceLink: string | null
source: string // workspace path to the calendar_sync JSON
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
dateKey: string // YYYY-MM-DD (local)
}
type DayGroup = {
dateKey: string
date: Date // local start-of-day
events: UpcomingEvent[]
}
function startOfDay(d: Date): Date {
const out = new Date(d)
out.setHours(0, 0, 0, 0)
return out
}
function addDays(d: Date, n: number): Date {
const out = new Date(d)
out.setDate(out.getDate() + n)
return out
}
function localDateKey(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// Parse an all-day calendar date string ("YYYY-MM-DD") into a local Date at midnight.
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 normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEvent | 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)
// Google's all-day end is exclusive (next day at 00:00) — keep as-is.
end = raw.end?.date ? parseAllDayDate(raw.end.date) : null
}
if (!start || Number.isNaN(start.getTime())) 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,
end,
isAllDay,
location: raw.location?.trim() || null,
htmlLink: raw.htmlLink ?? null,
conferenceLink,
source: sourcePath,
rawStart: raw.start,
rawEnd: raw.end,
dateKey: localDateKey(start),
}
}
function triggerMeetingCapture(event: UpcomingEvent, 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'))
}
// Always show today (anchor). For days within the window after today, include
// only those that actually have events — skip empty days.
function selectVisibleDays(allDays: DayGroup[]): DayGroup[] {
if (allDays.length === 0) return []
const out: DayGroup[] = [allDays[0]]
const cap = Math.min(allDays.length, UPCOMING_MAX_DAYS)
for (let i = 1; i < cap; i++) {
if (allDays[i].events.length > 0) out.push(allDays[i])
}
return out
}
function buildDayWindow(now: Date): DayGroup[] {
const today = startOfDay(now)
return Array.from({ length: UPCOMING_MAX_DAYS }, (_, i) => {
const date = addDays(today, i)
return { dateKey: localDateKey(date), date, events: [] }
})
}
function formatEventTimeRange(event: UpcomingEvent): string {
if (event.isAllDay) return 'All day'
const start = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (!event.end) return start
// If start and end are on different days, show date+time on both ends.
const sameDay = localDateKey(event.start) === localDateKey(event.end)
if (!sameDay) {
const startLong = event.start.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
const endLong = event.end.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
return `${startLong} ${endLong}`
}
const end = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return `${start} ${end}`
}
function UpcomingEvents() {
const [events, setEvents] = useState<UpcomingEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
const loadEvents = useCallback(async () => {
setLoading(true)
try {
const exists = await window.ipc.invoke('workspace:exists', { path: CALENDAR_DIR })
if (!exists.exists) {
setEvents([])
setError(null)
return
}
const entries = await window.ipc.invoke('workspace:readdir', {
path: CALENDAR_DIR,
opts: { recursive: false, includeHidden: false, includeStats: false },
})
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
const now = new Date()
const todayStart = startOfDay(now)
const windowEnd = addDays(todayStart, UPCOMING_MAX_DAYS) // exclusive
const settled = await Promise.allSettled(
jsonEntries.map(async (entry): Promise<UpcomingEvent | null> => {
const result = await window.ipc.invoke('workspace:readFile', {
path: entry.path,
encoding: 'utf8',
})
const raw = JSON.parse(result.data) as RawCalendarEvent
const ev = normalizeEvent(raw, entry.path)
if (!ev) return null
// Event must overlap the [now, windowEnd) range — i.e. not already ended,
// and not start after the window closes.
const effectiveEnd = ev.end ?? (ev.isAllDay ? addDays(ev.start, 1) : ev.start)
if (effectiveEnd <= now) return null
if (ev.start >= windowEnd) return null
return ev
}),
)
const collected: UpcomingEvent[] = []
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()
})
setEvents(collected)
setError(null)
} catch (err) {
console.error('Failed to load upcoming events:', err)
setError('Could not load upcoming events.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadEvents()
}, [loadEvents, refreshTick])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
setRefreshTick((t) => t + 1)
}, 250)
}
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isCalendarPath(event.path)) scheduleReload()
break
case 'moved':
if (isCalendarPath(event.from) || isCalendarPath(event.to)) scheduleReload()
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isCalendarPath)) scheduleReload()
break
}
})
// Refresh on the hour so day labels and "ended" filtering stay current.
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000)
return () => {
cleanup()
clearInterval(tick)
if (timeout) clearTimeout(timeout)
}
}, [])
const visibleDays = useMemo(() => {
const window = buildDayWindow(new Date())
const byKey = new Map(window.map((d) => [d.dateKey, d]))
for (const ev of events) {
byKey.get(ev.dateKey)?.events.push(ev)
}
return selectVisibleDays(window)
}, [events])
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
const now = new Date()
const todayKey = localDateKey(now)
return (
<section className="border-b border-border/60 px-6 pb-6 pt-5">
<div className="mx-auto w-full max-w-[760px]">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
Coming up
</h3>
{loading && events.length === 0 ? null : (
<span
className="text-[11px] uppercase tracking-wider"
style={{ color: 'var(--gm-text-faint)' }}
>
{totalVisible} {totalVisible === 1 ? 'event' : 'events'}
</span>
)}
</div>
{loading && events.length === 0 ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="py-4 text-sm text-muted-foreground">{error}</div>
) : (
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--gm-border)', background: 'var(--gm-bg)' }}
>
{visibleDays.map((day, idx) => (
<UpcomingDayRow
key={day.dateKey}
day={day}
isToday={day.dateKey === todayKey}
isLast={idx === visibleDays.length - 1}
/>
))}
</div>
)}
</div>
</section>
)
}
function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) {
const dayNum = day.date.getDate()
const month = day.date.toLocaleDateString([], { month: 'short' })
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
return (
<div
className="grid"
style={{
gridTemplateColumns: '96px 1fr',
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
}}
>
<div className="flex items-start gap-2 px-4 py-4">
<span
className="leading-none"
style={{ fontSize: 30, fontWeight: 400, color: 'var(--gm-text-strong)' }}
>
{dayNum}
</span>
<span className="flex flex-col leading-tight">
<span
className="flex items-center gap-1"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--gm-text)' }}
>
{month}
{isToday ? (
<span
aria-hidden
className="inline-block rounded-full"
style={{ width: 5, height: 5, background: 'var(--gm-accent)' }}
/>
) : null}
</span>
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
</span>
</div>
<div className="flex flex-col py-3 pr-3">
{day.events.length === 0 ? (
<div
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
style={{ color: 'var(--gm-text-faint)', minHeight: 40 }}
>
<span aria-hidden className="self-stretch shrink-0" style={{ width: 3 }} />
<span>{isToday ? 'No events today' : 'No events'}</span>
</div>
) : (
day.events.map((ev) => <UpcomingEventItem key={ev.id} event={ev} />)
)}
</div>
</div>
)
}
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
const handleOpen = useCallback(() => {
if (event.htmlLink) window.open(event.htmlLink, '_blank')
}, [event.htmlLink])
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
return (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen()
}
}}
title={titleAndLocation}
className={cn(
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
)}
style={{ color: 'var(--gm-text)', minHeight: 40 }}
>
<span
aria-hidden
className="self-stretch rounded-full"
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
/>
<span className="min-w-0 flex-1">
<span
className="block truncate"
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
>
{event.summary}
</span>
<span
className="mt-0.5 block truncate"
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
>
{formatEventTimeRange(event)}
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
</span>
</span>
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{event.conferenceLink ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
onNotesOnly={() => triggerMeetingCapture(event, false)}
/>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
onMouseDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<Mic className="size-3" />
Take notes
</button>
)}
</div>
</div>
)
}
function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
onJoinAndNotes: () => void
onNotesOnly: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
const target = e.target
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div
ref={ref}
style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
className="inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<Video className="size-3" />
Join & take notes
</button>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
aria-label="More meeting options"
className="inline-flex items-center justify-center px-1.5 py-1 transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderLeft: 'none',
borderTopRightRadius: 6,
borderBottomRightRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<ChevronDown className="size-3" />
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 50,
background: 'var(--gm-bg-card)',
border: '1px solid var(--gm-border)',
borderRadius: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
minWidth: 144,
overflow: 'hidden',
}}
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
className="flex w-full items-center gap-1 px-2 py-1.5 text-xs"
style={{ background: 'transparent', color: 'var(--gm-text)', whiteSpace: 'nowrap', border: 'none' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-row-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Mic className="size-3" />
Take notes only
</button>
</div>
)}
</div>
)
}
function formatMeetingName(name: string): string {
return name.replace(/\.md$/i, '').replace(/_/g, ' ')
}
function formatDateLabel(label: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(label)) return label || '—'
const date = new Date(`${label}T00:00:00`)
if (Number.isNaN(date.getTime())) return label
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function getMeetingButtonLabel(state: MeetingTranscriptionState): string {
switch (state) {
case 'connecting':
return 'Starting...'
case 'recording':
return 'Stop recording'
case 'stopping':
return 'Stopping...'
case 'idle':
default:
return 'Take meeting notes'
}
}
export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, meetingSummarizing = false }: MeetingsViewProps) {
const [notes, setNotes] = useState<MeetingNoteRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const exists = await window.ipc.invoke('workspace:exists', { path: MEETINGS_ROOT })
if (!exists.exists) {
setNotes([])
setError(null)
return
}
const entries = await window.ipc.invoke('workspace:readdir', {
path: MEETINGS_ROOT,
opts: {
recursive: true,
includeHidden: false,
includeStats: true,
},
})
const rows = entries
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
.map((entry) => {
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
const parts = relative.split('/')
const dateFolder = parts.find((part) => /^\d{4}-\d{2}-\d{2}$/.test(part)) ?? ''
return {
path: entry.path,
name: formatMeetingName(entry.name),
dateLabel: formatDateLabel(dateFolder),
mtimeMs: entry.stat?.mtimeMs ?? 0,
} satisfies MeetingNoteRow
})
.sort((a, b) => {
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs
return b.path.localeCompare(a.path)
})
setNotes(rows)
setError(null)
} catch (err) {
console.error('Failed to load meetings:', err)
setError('Could not load meeting notes.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isMeetingPath(event.path)) scheduleReload()
break
case 'moved':
if (isMeetingPath(event.from) || isMeetingPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isMeetingPath)) {
scheduleReload()
}
break
}
})
return () => {
cleanup()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const isBusy = meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing
const isRecording = meetingState === 'recording'
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Mic className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Meetings</h2>
</div>
<Button
type="button"
size="sm"
variant={isRecording ? 'destructive' : 'default'}
disabled={isBusy}
onClick={onTakeMeetingNotes}
>
{meetingSummarizing || meetingState === 'connecting' || meetingState === 'stopping' ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : isRecording ? (
<Square className="mr-2 size-3.5" />
) : (
<Mic className="mr-2 size-4" />
)}
{meetingSummarizing ? 'Generating notes...' : getMeetingButtonLabel(meetingState)}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Upcoming events and meeting notes.
</p>
</div>
<div className="flex-1 overflow-auto">
<UpcomingEvents />
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center justify-center px-8 py-10 text-center text-sm text-muted-foreground">
{error}
</div>
) : notes.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-10 text-center">
<div className="rounded-full bg-muted p-3">
<Mic className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No meeting notes yet. Use <strong>Take meeting notes</strong> to start one.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[56%]" />
<col className="w-[20%]" />
<col className="w-[24%]" />
</colgroup>
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Date</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Updated</th>
</tr>
</thead>
<tbody>
{notes.map((note) => (
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
>
<span className="block truncate">{note.name}</span>
</button>
</td>
<td className="px-4 py-3 align-top text-sm text-muted-foreground">{note.dateLabel}</td>
<td className="px-4 py-3 align-top text-sm text-muted-foreground">
{note.mtimeMs > 0 ? (formatRelativeTime(new Date(note.mtimeMs).toISOString()) || '—') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -100,7 +100,6 @@ import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time" import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
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 type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
import z from "zod" import z from "zod"
interface TreeNode { interface TreeNode {
@ -217,16 +216,13 @@ type SidebarContentPanelProps = {
selectedBackgroundTask?: string | null selectedBackgroundTask?: string | null
onNewChat?: () => void onNewChat?: () => void
onOpenSearch?: () => void onOpenSearch?: () => void
meetingState?: MeetingTranscriptionState
meetingSummarizing?: boolean
meetingAvailable?: boolean
onToggleMeeting?: () => void
isSearchOpen?: boolean isSearchOpen?: boolean
isMeetingActionActive?: boolean
isBrowserOpen?: boolean isBrowserOpen?: boolean
onToggleBrowser?: () => void onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void onOpenSuggestedTopics?: () => void
isMeetingsOpen?: boolean
onOpenMeetings?: () => void
isLiveNotesOpen?: boolean isLiveNotesOpen?: boolean
onOpenLiveNotes?: () => void onOpenLiveNotes?: () => void
isBgTasksOpen?: boolean isBgTasksOpen?: boolean
@ -481,16 +477,13 @@ export function SidebarContentPanel({
selectedBackgroundTask, selectedBackgroundTask,
onNewChat, onNewChat,
onOpenSearch, onOpenSearch,
meetingState = 'idle',
meetingSummarizing = false,
meetingAvailable = false,
onToggleMeeting,
isSearchOpen = false, isSearchOpen = false,
isMeetingActionActive = false,
isBrowserOpen = false, isBrowserOpen = false,
onToggleBrowser, onToggleBrowser,
isSuggestedTopicsOpen = false, isSuggestedTopicsOpen = false,
onOpenSuggestedTopics, onOpenSuggestedTopics,
isMeetingsOpen = false,
onOpenMeetings,
isLiveNotesOpen = false, isLiveNotesOpen = false,
onOpenLiveNotes, onOpenLiveNotes,
isBgTasksOpen = false, isBgTasksOpen = false,
@ -509,9 +502,9 @@ 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 isMeetingQuickActionSelected = isMeetingActionActive const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
@ -623,41 +616,6 @@ export function SidebarContentPanel({
<span>Search</span> <span>Search</span>
</button> </button>
)} )}
{meetingAvailable && onToggleMeeting && (
<button
type="button"
onClick={onToggleMeeting}
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
isMeetingQuickActionSelected
? "bg-sidebar-accent"
: "hover:bg-sidebar-accent",
meetingState === 'recording'
? "text-red-500"
: isMeetingQuickActionSelected
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground"
)}
>
{meetingSummarizing || meetingState === 'connecting' ? (
<LoaderIcon className="size-4 animate-spin" />
) : meetingState === 'recording' ? (
<Square className="size-4 animate-pulse" />
) : (
<Radio className="size-4" />
)}
<span>
{meetingSummarizing
? 'Generating notes…'
: meetingState === 'connecting'
? 'Starting…'
: meetingState === 'recording'
? 'Stop recording'
: 'Take meeting notes'}
</span>
</button>
)}
{onToggleBrowser && ( {onToggleBrowser && (
<button <button
type="button" type="button"
@ -718,6 +676,21 @@ export function SidebarContentPanel({
<span>Email</span> <span>Email</span>
</button> </button>
)} )}
{onOpenMeetings && (
<button
type="button"
onClick={onOpenMeetings}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isMeetingsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Mic className="size-4" />
<span>Meetings</span>
</button>
)}
{onOpenLiveNotes && ( {onOpenLiveNotes && (
<button <button
type="button" type="button"
@ -1147,6 +1120,10 @@ function KnowledgeSection({
const treeContainerRef = React.useRef<HTMLDivElement | null>(null) const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null) const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<string | null>(null) const [renameTarget, setRenameTarget] = useState<string | null>(null)
const visibleTree = React.useMemo(
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
[tree],
)
useEffect(() => { useEffect(() => {
if (!selectedPath) return if (!selectedPath) return
@ -1175,7 +1152,7 @@ function KnowledgeSection({
cancelled = true cancelled = true
if (rafId !== null) cancelAnimationFrame(rafId) if (rafId !== null) cancelAnimationFrame(rafId)
} }
}, [selectedPath, expandedPaths, tree]) }, [selectedPath, expandedPaths, visibleTree])
// Folder clicks highlight the folder; file clicks clear folder highlight // Folder clicks highlight the folder; file clicks clear folder highlight
const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => { const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => {
@ -1257,7 +1234,7 @@ function KnowledgeSection({
<SidebarGroupContent className="flex-1 overflow-y-auto"> <SidebarGroupContent className="flex-1 overflow-y-auto">
<div ref={treeContainerRef}> <div ref={treeContainerRef}>
<SidebarMenu> <SidebarMenu>
{tree.map((item, index) => ( {visibleTree.map((item, index) => (
<Tree <Tree
key={index} key={index}
item={item} item={item}