email view

This commit is contained in:
Arjun 2026-05-13 12:36:37 +05:30
parent e4244a8ce5
commit 296f7c75b7
7 changed files with 1065 additions and 47 deletions

View file

@ -58,6 +58,492 @@
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px);
} }
.gmail-shell {
display: flex;
height: 100%;
min-height: 0;
width: 100%;
overflow: hidden;
background: #f6f8fc;
color: #202124;
font-family: "Google Sans", Roboto, Arial, sans-serif;
}
.gmail-left-nav {
width: 236px;
flex-shrink: 0;
padding: 16px 12px;
background: #f6f8fc;
border-right: 1px solid #e4e7ee;
}
.gmail-compose-main {
display: flex;
align-items: center;
justify-content: center;
min-width: 132px;
height: 56px;
margin-bottom: 18px;
border: none;
border-radius: 16px;
background: #c2e7ff;
color: #001d35;
font-size: 14px;
font-weight: 500;
box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3);
cursor: pointer;
}
.gmail-nav-item {
display: flex;
align-items: center;
gap: 14px;
height: 32px;
padding: 0 14px;
border-radius: 0 16px 16px 0;
color: #444746;
font-size: 14px;
}
.gmail-nav-item-active {
background: #d3e3fd;
color: #001d35;
font-weight: 700;
}
.gmail-main {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 8px 12px 12px 0;
}
.gmail-topbar {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 12px;
}
.gmail-search {
display: flex;
align-items: center;
gap: 12px;
width: min(720px, 100%);
height: 46px;
padding: 0 16px;
border-radius: 24px;
background: #eaf1fb;
color: #5f6368;
}
.gmail-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #202124;
font-size: 15px;
}
.gmail-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: transparent;
color: #5f6368;
cursor: pointer;
}
.gmail-icon-button:hover {
background: rgba(60, 64, 67, 0.08);
}
.gmail-layout {
display: grid;
grid-template-columns: minmax(420px, 0.45fr) minmax(480px, 0.55fr);
min-height: 0;
flex: 1;
overflow: hidden;
border-radius: 16px;
background: #fff;
border: 1px solid #e4e7ee;
}
.gmail-list {
min-width: 0;
min-height: 0;
overflow: auto;
border-right: 1px solid #e4e7ee;
}
.gmail-list-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
justify-content: space-between;
height: 44px;
padding: 0 16px;
align-items: center;
background: #fff;
border-bottom: 1px solid #e8eaed;
color: #5f6368;
font-size: 12px;
}
.gmail-row {
display: grid;
grid-template-columns: 28px 28px minmax(132px, 0.28fr) minmax(0, 1fr) 72px;
align-items: center;
width: 100%;
min-height: 44px;
padding: 0 12px;
border: none;
border-bottom: 1px solid #f1f3f4;
background: #fff;
color: #202124;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.gmail-row:hover,
.gmail-row-selected {
box-shadow: inset 3px 0 0 #1a73e8;
background: #f2f6fc;
}
.gmail-row-check {
width: 14px;
height: 14px;
border: 2px solid #bdc1c6;
border-radius: 2px;
}
.gmail-row-star {
color: #bdc1c6;
line-height: 0;
}
.gmail-row-sender,
.gmail-row-content strong,
.gmail-row-date {
font-size: 13px;
font-weight: 700;
}
.gmail-row-sender,
.gmail-row-content,
.gmail-row-content span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.gmail-row-content {
display: flex;
gap: 6px;
color: #5f6368;
font-size: 13px;
}
.gmail-row-content strong {
flex-shrink: 0;
color: #202124;
}
.gmail-row-date {
justify-self: end;
color: #5f6368;
white-space: nowrap;
}
.gmail-detail {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
background: #fff;
}
.gmail-detail-toolbar {
display: flex;
align-items: center;
gap: 4px;
height: 44px;
padding: 0 12px;
border-bottom: 1px solid #e8eaed;
}
.gmail-back-button {
display: none;
}
.gmail-thread-scroll {
min-height: 0;
overflow: auto;
padding: 24px 32px 40px;
}
.gmail-thread-subject-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.gmail-thread-subject-row h1 {
margin: 0;
color: #202124;
font-size: 22px;
font-weight: 400;
line-height: 1.35;
}
.gmail-thread-subject-row span {
border-radius: 4px;
background: #e8eaed;
padding: 2px 6px;
color: #5f6368;
font-size: 12px;
}
.gmail-message-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.gmail-message {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
padding: 12px 0;
}
.gmail-message-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: #fff;
font-weight: 500;
}
.gmail-message-main {
min-width: 0;
}
.gmail-message-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.gmail-message-from {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gmail-message-from strong,
.gmail-message-from span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.gmail-message-from strong {
font-size: 14px;
}
.gmail-message-from span,
.gmail-message-to,
.gmail-message-date {
color: #5f6368;
font-size: 12px;
}
.gmail-message-date {
flex-shrink: 0;
}
.gmail-message-body {
margin-top: 14px;
max-width: 820px;
color: #202124;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.gmail-thread-actions {
display: flex;
gap: 8px;
margin: 28px 0 16px 52px;
}
.gmail-thread-actions button {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 18px;
border: 1px solid #dadce0;
border-radius: 18px;
background: #fff;
color: #3c4043;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.gmail-thread-actions button:hover {
background: #f8fafd;
}
.gmail-compose-card {
max-width: 720px;
margin-left: 52px;
border: 1px solid #dadce0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(60, 64, 67, 0.18);
}
.gmail-compose-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
background: #f2f6fc;
font-size: 13px;
font-weight: 500;
}
.gmail-compose-header button,
.gmail-compose-link {
border: none;
background: transparent;
color: #5f6368;
cursor: pointer;
}
.gmail-compose-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 12px;
border-bottom: 1px solid #f1f3f4;
color: #5f6368;
font-size: 13px;
}
.gmail-compose-line input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: #202124;
font: inherit;
}
.gmail-compose-card textarea {
display: block;
width: 100%;
min-height: 120px;
border: none;
outline: none;
resize: none;
padding: 12px;
color: #202124;
font: 14px/1.5 Arial, sans-serif;
}
.gmail-compose-actions {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-top: 1px solid #f1f3f4;
}
.gmail-send-button {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 18px;
border: none;
border-radius: 18px;
background: #0b57d0;
color: #fff;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.gmail-empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
border-radius: 16px;
background: #fff;
color: #5f6368;
font-size: 14px;
}
@media (max-width: 980px) {
.gmail-left-nav {
display: none;
}
.gmail-main {
padding-left: 8px;
}
.gmail-layout {
grid-template-columns: 1fr;
}
.gmail-list:has(+ .gmail-detail) {
display: none;
}
.gmail-back-button {
display: inline-flex;
}
.gmail-thread-scroll {
padding: 18px;
}
.gmail-compose-card,
.gmail-thread-actions {
margin-left: 0;
}
}
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);

View file

@ -24,6 +24,7 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view'; 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 { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
Conversation, Conversation,
@ -178,6 +179,7 @@ const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
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 BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) => const clampNumber = (value: number, min: number, max: number) =>
@ -309,6 +311,7 @@ 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 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 isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => { const getSuggestedTopicTargetFolder = (category?: string) => {
@ -557,6 +560,7 @@ type ViewState =
| { type: 'task'; name: string } | { type: 'task'; name: string }
| { type: 'suggested-topics' } | { type: 'suggested-topics' }
| { type: 'live-notes' } | { type: 'live-notes' }
| { type: 'email' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean { function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false if (a.type !== b.type) return false
@ -710,11 +714,13 @@ function App() {
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = 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 [expandedFrom, setExpandedFrom] = useState<{ const [expandedFrom, setExpandedFrom] = useState<{
path: string | null path: string | null
graph: boolean graph: boolean
suggestedTopics: boolean suggestedTopics: boolean
liveNotes: boolean liveNotes: boolean
email: boolean
} | null>(null) } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -1041,6 +1047,7 @@ function App() {
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
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 (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2753,7 +2760,7 @@ function App() {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
@ -2762,7 +2769,7 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay]) }, [fileTabs, dismissBrowserOverlay])
@ -2781,32 +2788,43 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(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) 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)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
return return
} }
if (isEmailTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
return
}
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => { const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId) const closingTab = fileTabs.find(t => t.id === tabId)
if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -2829,7 +2847,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(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)
@ -2843,21 +2861,30 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(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) 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)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -2888,12 +2915,13 @@ 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) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
email: isEmailOpen,
}) })
} else { } else {
setExpandedFrom(null) setExpandedFrom(null)
@ -2902,8 +2930,8 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen]) }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, 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(() => {
@ -3035,12 +3063,13 @@ 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) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
email: isEmailOpen,
}) })
} }
dismissBrowserOverlay() dismissBrowserOverlay()
@ -3048,27 +3077,35 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, 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) 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) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.liveNotes) { } else if (expandedFrom.liveNotes) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (expandedFrom.email) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else if (expandedFrom.path) { } else if (expandedFrom.path) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(expandedFrom.path) setSelectedPath(expandedFrom.path)
} }
setExpandedFrom(null) setExpandedFrom(null)
@ -3078,12 +3115,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 (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, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) }, [selectedBackgroundTask, isEmailOpen, 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]
@ -3162,12 +3200,37 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureEmailFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isEmailTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: EMAIL_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const openEmailView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setIsEmailOpen(true)
ensureEmailFileTab()
}, [ensureEmailFileTab])
const openBgTasksView = useCallback(() => { const openBgTasksView = useCallback(() => {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
@ -3184,7 +3247,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) 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.
@ -3199,7 +3262,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -3212,7 +3275,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
@ -3225,7 +3288,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
ensureSuggestedTopicsFileTab() ensureSuggestedTopicsFileTab()
return return
case 'live-notes': case 'live-notes':
@ -3236,9 +3299,24 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
ensureLiveNotesFileTab() ensureLiveNotesFileTab()
return return
case 'email':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
ensureEmailFileTab()
return
case 'chat': case 'chat':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3247,7 +3325,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -3255,7 +3333,7 @@ function App() {
} }
return return
} }
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureEmailFileTab, 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
@ -3577,7 +3655,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 && !selectedBackgroundTask && !isBrowserOpen const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !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') {
@ -3650,11 +3728,11 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => { const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey const mod = e.metaKey || e.ctrlKey
if (!mod) return if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && isChatSidebarOpen) const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || 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) const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen)
const selectedKnowledgePath = isGraphOpen const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH ? GRAPH_TAB_PATH
: isSuggestedTopicsOpen : isSuggestedTopicsOpen
@ -3663,6 +3741,8 @@ function App() {
? LIVE_NOTES_TAB_PATH ? LIVE_NOTES_TAB_PATH
: isBgTasksOpen : isBgTasksOpen
? BG_TASKS_TAB_PATH ? BG_TASKS_TAB_PATH
: isEmailOpen
? EMAIL_TAB_PATH
: selectedPath : selectedPath
const targetFileTabId = activeFileTabId ?? ( const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath selectedKnowledgePath
@ -3717,7 +3797,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, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, 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') {
@ -3742,7 +3822,7 @@ function App() {
}), }),
}, },
})) }))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -3868,14 +3948,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 && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !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 && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4465,7 +4545,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 || isBrowserOpen) const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || 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(() => {
@ -4482,7 +4562,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) { if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
} }
}}> }}>
@ -4515,7 +4595,7 @@ function App() {
onNewChat: handleNewChatTab, onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive() cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
} }
@ -4526,7 +4606,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 || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || 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
@ -4550,14 +4630,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 || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || 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 || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || 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 {
@ -4591,6 +4671,8 @@ function App() {
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
isBgTasksOpen={isBgTasksOpen} isBgTasksOpen={isBgTasksOpen}
onOpenBgTasks={openBgTasksView} onOpenBgTasks={openBgTasksView}
isEmailOpen={isEmailOpen}
onOpenEmail={openEmailView}
/> />
<SidebarInset <SidebarInset
className={cn( className={cn(
@ -4610,7 +4692,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -4618,7 +4700,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 || (selectedPath != null && isBaseFilePath(selectedPath)))} allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/> />
) : ( ) : (
<TabBar <TabBar
@ -4671,7 +4753,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent> <TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4686,7 +4768,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4701,7 +4783,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) && ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4754,6 +4836,10 @@ function App() {
}} }}
/> />
</div> </div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView />
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? ( ) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView <BasesView

View file

@ -0,0 +1,393 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Forward, Inbox, LoaderIcon, Mail, MoreVertical, RefreshCw, Reply, Search, Send, Star } from 'lucide-react'
import type { blocks } from '@x/shared'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
type GmailThread = blocks.GmailThread
type GmailThreadMessage = blocks.GmailThreadMessage
type IndexedThread = {
threadId: string
lastDateMs: number
sourcePath: string
}
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
function parseThreadId(path: string, markdown: string): string | null {
const fromBody = markdown.match(/\*\*Thread ID:\*\*\s*([^\s]+)/)?.[1]?.trim()
if (fromBody) return fromBody
return path.split('/').pop()?.replace(/\.md$/i, '') || null
}
function parseLatestDateMs(markdown: string, fallbackMs?: number): number {
const matches = Array.from(markdown.matchAll(/^\*\*Date:\*\*\s*(.+)$/gm))
for (let i = matches.length - 1; i >= 0; i -= 1) {
const raw = matches[i]?.[1]?.trim()
if (!raw) continue
const ms = Date.parse(raw)
if (!Number.isNaN(ms)) return ms
}
return fallbackMs ?? 0
}
function formatInboxTime(value?: string): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
if (sameDay) return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
function formatFullDate(value?: string): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString([], {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function extractName(from?: string): string {
if (!from) return 'Unknown'
const match = from.match(/^([^<]+)</)
if (match?.[1]) return match[1].replace(/^["']|["']$/g, '').trim()
const address = from.match(/<?([^<>\s]+@[^<>\s]+)>?/)?.[1] ?? from
return address.replace(/@.*/, '').replace(/[._+]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function extractAddress(from?: string): string {
if (!from) return ''
return from.match(/<([^>]+)>/)?.[1] ?? from
}
function snippet(text?: string): string {
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
}
function getInitial(from?: string): string {
return (extractName(from)[0] || '?').toUpperCase()
}
const AVATAR_COLORS = ['#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900', '#00796b', '#c62828', '#1565c0']
function avatarColor(from?: string): string {
const value = from || 'unknown'
let hash = 0
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) >>> 0
return AVATAR_COLORS[hash % AVATAR_COLORS.length]
}
function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
return thread.messages[thread.messages.length - 1]
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = []
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit)
results.push(...await Promise.all(batch.map(mapper)))
}
return results
}
type ComposeMode = 'reply' | 'forward'
function ComposeBox({
mode,
thread,
onClose,
}: {
mode: ComposeMode
thread: GmailThread
onClose: () => void
}) {
const latest = latestMessage(thread)
const [body, setBody] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.max(120, el.scrollHeight)}px`
}, [body])
return (
<div className="gmail-compose-card">
<div className="gmail-compose-header">
<span>{mode === 'reply' ? 'Reply' : 'Forward'}</span>
<button type="button" onClick={onClose} aria-label="Close compose">x</button>
</div>
<div className="gmail-compose-line">
<span>{mode === 'reply' ? 'To' : 'Recipients'}</span>
<input value={to} placeholder="Recipients" readOnly={mode === 'reply'} />
</div>
{mode === 'forward' && (
<div className="gmail-compose-line">
<span>Subject</span>
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
</div>
)}
<textarea
ref={textareaRef}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={mode === 'reply' ? 'Write your reply...' : 'Write a message...'}
/>
<div className="gmail-compose-actions">
<button
type="button"
className="gmail-send-button"
onClick={() => {
toast('Sending from this view needs Gmail send scope. Draft UI is ready.', 'info')
}}
>
<Send size={15} />
Send
</button>
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
</div>
</div>
)
}
function ThreadDetail({
thread,
onBack,
}: {
thread: GmailThread
onBack: () => void
}) {
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
return (
<div className="gmail-detail">
<div className="gmail-detail-toolbar">
<button type="button" className="gmail-icon-button gmail-back-button" onClick={onBack} aria-label="Back to inbox">
<span></span>
</button>
<button type="button" className="gmail-icon-button" aria-label="Archive"><Archive size={18} /></button>
<button type="button" className="gmail-icon-button" aria-label="More"><MoreVertical size={18} /></button>
</div>
<div className="gmail-thread-scroll">
<div className="gmail-thread-subject-row">
<h1>{thread.subject || '(No subject)'}</h1>
<span>Inbox</span>
</div>
<div className="gmail-message-stack">
{thread.messages.map((message, index) => {
const isLast = index === thread.messages.length - 1
return (
<div key={message.id || index} className={cn('gmail-message', isLast && 'gmail-message-open')}>
<div className="gmail-message-avatar" style={{ backgroundColor: avatarColor(message.from) }}>
{getInitial(message.from)}
</div>
<div className="gmail-message-main">
<div className="gmail-message-meta">
<div className="gmail-message-from">
<strong>{extractName(message.from)}</strong>
<span>{extractAddress(message.from)}</span>
</div>
<div className="gmail-message-date">{formatFullDate(message.date)}</div>
</div>
<div className="gmail-message-to">to {message.to || 'me'}</div>
<div className="gmail-message-body">{message.body || '(No message body)'}</div>
</div>
</div>
)
})}
</div>
<div className="gmail-thread-actions">
<button type="button" onClick={() => setComposeMode('reply')}>
<Reply size={16} />
Reply
</button>
<button type="button" onClick={() => setComposeMode('forward')}>
<Forward size={16} />
Forward
</button>
</div>
{composeMode && (
<ComposeBox
mode={composeMode}
thread={thread}
onClose={() => setComposeMode(null)}
/>
)}
</div>
</div>
)
}
export function EmailView() {
const [threads, setThreads] = useState<GmailThread[]>([])
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const loadThreads = useCallback(async () => {
setLoading(true)
setError(null)
try {
const entries = await window.ipc.invoke('workspace:readdir', {
path: 'gmail_sync',
opts: { includeStats: true },
})
const cutoff = Date.now() - TWO_DAYS_MS
const indexed: IndexedThread[] = []
await Promise.all(entries
.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md') && entry.name !== 'sync_state.json')
.map(async (entry) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: entry.path, encoding: 'utf8' })
const threadId = parseThreadId(entry.path, result.data)
if (!threadId) return
const lastDateMs = parseLatestDateMs(result.data, entry.stat?.mtimeMs)
if (lastDateMs < cutoff) return
indexed.push({ threadId, lastDateMs, sourcePath: entry.path })
} catch {
const threadId = entry.name.replace(/\.md$/i, '')
const lastDateMs = entry.stat?.mtimeMs ?? 0
if (lastDateMs >= cutoff) indexed.push({ threadId, lastDateMs, sourcePath: entry.path })
}
}))
const recent = indexed
.sort((a, b) => b.lastDateMs - a.lastDateMs)
const hydrated = await mapWithConcurrency(recent, 6, async (item) => {
const result = await window.ipc.invoke('gmail:getThread', { threadId: item.threadId })
if (result.thread) return result.thread
console.warn('Failed to hydrate Gmail thread', item.sourcePath, result.error)
return null
})
const nextThreads = hydrated
.filter((thread): thread is GmailThread => Boolean(thread))
.sort((a, b) => {
const aDate = Date.parse(latestMessage(a)?.date || a.date || '')
const bDate = Date.parse(latestMessage(b)?.date || b.date || '')
return (Number.isNaN(bDate) ? 0 : bDate) - (Number.isNaN(aDate) ? 0 : aDate)
})
setThreads(nextThreads)
setSelectedThreadId(current => current && nextThreads.some(thread => thread.threadId === current) ? current : nextThreads[0]?.threadId ?? null)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setThreads([])
setSelectedThreadId(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadThreads()
}, [loadThreads])
const filteredThreads = useMemo(() => {
const normalized = query.trim().toLowerCase()
if (!normalized) return threads
return threads.filter((thread) => {
const latest = latestMessage(thread)
return [
thread.subject,
latest?.from,
latest?.to,
latest?.body,
].some(value => (value || '').toLowerCase().includes(normalized))
})
}, [query, threads])
const selectedThread = filteredThreads.find(thread => thread.threadId === selectedThreadId) ?? filteredThreads[0] ?? null
return (
<div className="gmail-shell">
<div className="gmail-left-nav">
<button type="button" className="gmail-compose-main">Compose</button>
<div className="gmail-nav-item gmail-nav-item-active"><Inbox size={18} /> Inbox</div>
<div className="gmail-nav-item"><Star size={18} /> Starred</div>
<div className="gmail-nav-item"><Mail size={18} /> Sent</div>
</div>
<div className="gmail-main">
<div className="gmail-topbar">
<div className="gmail-search">
<Search size={18} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search mail"
/>
</div>
<button type="button" className="gmail-icon-button" onClick={() => void loadThreads()} aria-label="Refresh">
{loading ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
</button>
</div>
{error ? (
<div className="gmail-empty-state">Could not load mail: {error}</div>
) : selectedThread ? (
<div className="gmail-layout">
<div className="gmail-list" aria-label="Recent emails">
<div className="gmail-list-header">
<span>Last 2 days</span>
<span>{filteredThreads.length} threads</span>
</div>
{filteredThreads.map((thread) => {
const latest = latestMessage(thread)
const isSelected = thread.threadId === selectedThread.threadId
return (
<button
key={thread.threadId}
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected')}
onClick={() => setSelectedThreadId(thread.threadId)}
>
<span className="gmail-row-check" />
<span className="gmail-row-star"><Star size={16} /></span>
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
<span className="gmail-row-content">
<strong>{thread.subject || '(No subject)'}</strong>
<span>{snippet(latest?.body || thread.latest_email)}</span>
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
)
})}
</div>
<ThreadDetail
thread={selectedThread}
onBack={() => setSelectedThreadId(null)}
/>
</div>
) : (
<div className="gmail-empty-state">
{loading ? 'Loading recent Gmail threads...' : 'No Gmail threads found from the last 2 days.'}
</div>
)}
</div>
</div>
)
}

View file

@ -26,6 +26,7 @@ import {
Lightbulb, Lightbulb,
ListChecks, ListChecks,
LoaderIcon, LoaderIcon,
Mail,
Settings, Settings,
Square, Square,
Trash2, Trash2,
@ -220,6 +221,8 @@ type SidebarContentPanelProps = {
onOpenLiveNotes?: () => void onOpenLiveNotes?: () => void
isBgTasksOpen?: boolean isBgTasksOpen?: boolean
onOpenBgTasks?: () => void onOpenBgTasks?: () => void
isEmailOpen?: boolean
onOpenEmail?: () => void
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [ const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -482,6 +485,8 @@ export function SidebarContentPanel({
onOpenLiveNotes, onOpenLiveNotes,
isBgTasksOpen = false, isBgTasksOpen = false,
onOpenBgTasks, onOpenBgTasks,
isEmailOpen = false,
onOpenEmail,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection() const { activeSection, setActiveSection } = useSidebarSection()
@ -499,6 +504,7 @@ export function SidebarContentPanel({
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => { const handleRowboatLogin = useCallback(async () => {
try { try {
@ -687,6 +693,21 @@ export function SidebarContentPanel({
<span>Background tasks</span> <span>Background tasks</span>
</button> </button>
)} )}
{onOpenEmail && (
<button
type="button"
onClick={onOpenEmail}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isEmailQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Mail className="size-4" />
<span>Email</span>
</button>
)}
{onOpenLiveNotes && ( {onOpenLiveNotes && (
<button <button
type="button" type="button"

View file

@ -31,6 +31,15 @@ export interface GmailThreadSnapshot {
date?: string; date?: string;
latest_email?: string; latest_email?: string;
past_summary?: string; past_summary?: string;
messages: Array<{
id?: string;
from?: string;
to?: string;
cc?: string;
date?: string;
subject?: string;
body?: string;
}>;
} }
function summarizeGmailSync(threads: SyncedThread[]): string { function summarizeGmailSync(threads: SyncedThread[]): string {
@ -158,8 +167,10 @@ export async function fetchThreadSnapshot(threadId: string): Promise<GmailThread
const parsed = messages.map((msg) => { const parsed = messages.map((msg) => {
const headers = msg.payload?.headers || []; const headers = msg.payload?.headers || [];
return { return {
id: msg.id || undefined,
from: headerValue(headers, 'From') || 'Unknown', from: headerValue(headers, 'From') || 'Unknown',
to: headerValue(headers, 'To'), to: headerValue(headers, 'To'),
cc: headerValue(headers, 'Cc'),
date: headerValue(headers, 'Date'), date: headerValue(headers, 'Date'),
subject: headerValue(headers, 'Subject') || '(No Subject)', subject: headerValue(headers, 'Subject') || '(No Subject)',
body: msg.payload ? normalizeBody(getBody(msg.payload)) : '', body: msg.payload ? normalizeBody(getBody(msg.payload)) : '',
@ -186,6 +197,7 @@ export async function fetchThreadSnapshot(threadId: string): Promise<GmailThread
date: latest.date, date: latest.date,
latest_email: latest.body, latest_email: latest.body,
past_summary: earlierSummary || undefined, past_summary: earlierSummary || undefined,
messages: parsed,
}; };
} }

View file

@ -102,6 +102,26 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>; export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const GmailThreadMessageSchema = z.object({
id: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
cc: z.string().optional(),
date: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
export const GmailThreadSchema = EmailBlockSchema.extend({
threadId: z.string(),
threadUrl: z.string().url(),
messages: z.array(GmailThreadMessageSchema),
});
export type GmailThread = z.infer<typeof GmailThreadSchema>;
export const EmailsBlockSchema = z.object({ export const EmailsBlockSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
emails: z.array(EmailBlockSchema), emails: z.array(EmailBlockSchema),

View file

@ -17,7 +17,7 @@ import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js'; import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js'; import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js'; import { BrowserStateSchema } from './browser-control.js';
import { EmailBlockSchema } from './blocks.js'; import { GmailThreadSchema } from './blocks.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +128,7 @@ const ipcSchemas = {
threadId: z.string().min(1), threadId: z.string().min(1),
}), }),
res: z.object({ res: z.object({
thread: EmailBlockSchema.nullable(), thread: GmailThreadSchema.nullable(),
error: z.string().optional(), error: z.string().optional(),
}), }),
}, },