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);
}
.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 {
--radius-sm: calc(var(--radius) - 4px);
--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 { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@ -178,6 +179,7 @@ const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
const EMAIL_TAB_PATH = '__rowboat_email__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
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 isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_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 getSuggestedTopicTargetFolder = (category?: string) => {
@ -557,6 +560,7 @@ type ViewState =
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
| { type: 'live-notes' }
| { type: 'email' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -710,11 +714,13 @@ function App() {
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
const [isEmailOpen, setIsEmailOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
suggestedTopics: boolean
liveNotes: boolean
email: boolean
} | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -1041,6 +1047,7 @@ function App() {
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
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.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2753,7 +2760,7 @@ function App() {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path)
return
}
@ -2762,7 +2769,7 @@ function App() {
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay])
@ -2781,32 +2788,43 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return
}
if (isLiveNotesTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true)
return
}
if (isEmailTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => {
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)
initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -2829,7 +2847,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -2843,21 +2861,30 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true)
} else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@ -2888,12 +2915,13 @@ function App() {
dismissBrowserOverlay()
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen,
email: isEmailOpen,
})
} else {
setExpandedFrom(null)
@ -2902,8 +2930,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen])
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -3035,12 +3063,13 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// 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({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen,
email: isEmailOpen,
})
}
dismissBrowserOverlay()
@ -3048,27 +3077,35 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay])
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.liveNotes) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true)
} else if (expandedFrom.email) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
@ -3078,12 +3115,13 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isEmailOpen) return { type: 'email' }
if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
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 last = stack[stack.length - 1]
@ -3162,12 +3200,37 @@ function App() {
setActiveFileTabId(id)
}, [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(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
@ -3184,7 +3247,7 @@ function App() {
// visible in the middle pane.
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@ -3199,7 +3262,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -3212,7 +3275,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -3225,7 +3288,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
ensureSuggestedTopicsFileTab()
return
case 'live-notes':
@ -3236,9 +3299,24 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true)
ensureLiveNotesFileTab()
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':
setSelectedPath(null)
setIsGraphOpen(false)
@ -3247,7 +3325,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@ -3255,7 +3333,7 @@ function App() {
}
return
}
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
}, [ensureEmailFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@ -3577,7 +3655,7 @@ function App() {
}, [])
// 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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3650,11 +3728,11 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
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
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: '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
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
@ -3663,6 +3741,8 @@ function App() {
? LIVE_NOTES_TAB_PATH
: isBgTasksOpen
? BG_TASKS_TAB_PATH
: isEmailOpen
? EMAIL_TAB_PATH
: selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
@ -3717,7 +3797,7 @@ function App() {
}
document.addEventListener('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') => {
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)
setIsRightPaneMaximized(false)
}
@ -3868,14 +3948,14 @@ function App() {
},
openGraph: () => {
// 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)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -4465,7 +4545,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: 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 shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4482,7 +4562,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<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 })
}
}}>
@ -4515,7 +4595,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4526,7 +4606,7 @@ function App() {
return
}
// 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))
loadRun(runIdToLoad)
return
@ -4550,14 +4630,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} 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))
handleNewChat()
} else {
@ -4591,6 +4671,8 @@ function App() {
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
isBgTasksOpen={isBgTasksOpen}
onOpenBgTasks={openBgTasksView}
isEmailOpen={isEmailOpen}
onOpenEmail={openEmailView}
/>
<SidebarInset
className={cn(
@ -4610,7 +4692,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -4618,7 +4700,7 @@ function App() {
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
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
@ -4671,7 +4753,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4686,7 +4768,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4701,7 +4783,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4754,6 +4836,10 @@ function App() {
}}
/>
</div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView />
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<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,
ListChecks,
LoaderIcon,
Mail,
Settings,
Square,
Trash2,
@ -220,6 +221,8 @@ type SidebarContentPanelProps = {
onOpenLiveNotes?: () => void
isBgTasksOpen?: boolean
onOpenBgTasks?: () => void
isEmailOpen?: boolean
onOpenEmail?: () => void
} & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -482,6 +485,8 @@ export function SidebarContentPanel({
onOpenLiveNotes,
isBgTasksOpen = false,
onOpenBgTasks,
isEmailOpen = false,
onOpenEmail,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
@ -499,6 +504,7 @@ export function SidebarContentPanel({
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => {
try {
@ -687,6 +693,21 @@ export function SidebarContentPanel({
<span>Background tasks</span>
</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 && (
<button
type="button"

View file

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

View file

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