mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
email view
This commit is contained in:
parent
e3eac3cfdd
commit
3d5bd1a677
7 changed files with 1065 additions and 47 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -4471,7 +4551,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(() => {
|
||||
|
|
@ -4488,7 +4568,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 })
|
||||
}
|
||||
}}>
|
||||
|
|
@ -4521,7 +4601,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)
|
||||
}
|
||||
|
||||
|
|
@ -4532,7 +4612,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
|
||||
|
|
@ -4556,14 +4636,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 {
|
||||
|
|
@ -4597,6 +4677,8 @@ function App() {
|
|||
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
|
||||
isBgTasksOpen={isBgTasksOpen}
|
||||
onOpenBgTasks={openBgTasksView}
|
||||
isEmailOpen={isEmailOpen}
|
||||
onOpenEmail={openEmailView}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4616,7 +4698,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 ?? ''}
|
||||
|
|
@ -4624,7 +4706,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
|
||||
|
|
@ -4677,7 +4759,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
|
||||
|
|
@ -4692,7 +4774,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
|
||||
|
|
@ -4707,7 +4789,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
|
||||
|
|
@ -4760,6 +4842,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
|
||||
|
|
|
|||
393
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
393
apps/x/apps/renderer/src/components/email-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
Lightbulb,
|
||||
ListChecks,
|
||||
LoaderIcon,
|
||||
Mail,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
|
|
@ -230,6 +231,8 @@ type SidebarContentPanelProps = {
|
|||
onOpenLiveNotes?: () => void
|
||||
isBgTasksOpen?: boolean
|
||||
onOpenBgTasks?: () => void
|
||||
isEmailOpen?: boolean
|
||||
onOpenEmail?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -492,6 +495,8 @@ export function SidebarContentPanel({
|
|||
onOpenLiveNotes,
|
||||
isBgTasksOpen = false,
|
||||
onOpenBgTasks,
|
||||
isEmailOpen = false,
|
||||
onOpenEmail,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -509,6 +514,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 {
|
||||
|
|
@ -697,6 +703,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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { RowboatApiConfig } from './rowboat-account.js';
|
|||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema } from './blocks.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -129,7 +129,7 @@ const ipcSchemas = {
|
|||
threadId: z.string().min(1),
|
||||
}),
|
||||
res: z.object({
|
||||
thread: EmailBlockSchema.nullable(),
|
||||
thread: GmailThreadSchema.nullable(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue