mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +02:00
parent
7dcf8eea70
commit
6492cf65b5
4 changed files with 984 additions and 100 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal file
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue