diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d66ce23b..66846b73 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -775,6 +775,8 @@ function App() { const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) + const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) + const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null graph: boolean @@ -5126,8 +5128,11 @@ function App() { recordingMeetingSource={recordingMeetingSource} onToggleMeetingRecording={() => { void handleToggleMeeting() }} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} - isEmailOpen={isEmailOpen} - onOpenEmail={openEmailView} + onOpenEmail={(threadId) => { + setEmailInitialThreadId(threadId ?? null) + setEmailThreadIdVersion((v) => v + 1) + openEmailView() + }} /> ) : isEmailOpen ? (
- +
) : isWorkspaceOpen ? (
diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 674547e0..49566d11 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -817,12 +817,28 @@ function clearLoadingFlag(state: SectionState | null): SectionState { return { ...state, loadingPage: false } } -export function EmailView() { +export type EmailViewProps = { + /** If provided, the view opens with this thread already expanded. */ + initialThreadId?: string | null + /** Bump to re-focus on the same threadId after navigating away inside the view. */ + threadIdVersion?: number +} + +export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) { const [important, setImportant] = useState(() => clearLoadingFlag(persistedImportant)) const [other, setOther] = useState(() => clearLoadingFlag(persistedOther)) const hadPersistedDataOnMount = useRef(persistedImportant !== null) - const [selectedThreadId, setSelectedThreadId] = useState(null) - const [openedThreadIds, setOpenedThreadIds] = useState([]) + const [selectedThreadId, setSelectedThreadId] = useState(initialThreadId ?? null) + const [openedThreadIds, setOpenedThreadIds] = useState(initialThreadId ? [initialThreadId] : []) + useEffect(() => { + setSelectedThreadId(initialThreadId ?? null) + if (initialThreadId) { + setOpenedThreadIds((prev) => { + const without = prev.filter((id) => id !== initialThreadId) + return [...without, initialThreadId].slice(-MAX_KEPT_OPEN) + }) + } + }, [initialThreadId, threadIdVersion]) const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current) const [error, setError] = useState(null) const [query, setQuery] = useState('') diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index c2b45dd1..c7d04266 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -200,8 +200,7 @@ type SidebarContentPanelProps = { recordingMeetingSource?: string | null onToggleMeetingRecording?: () => void onOpenBgTasks?: () => void - isEmailOpen?: boolean - onOpenEmail?: () => void + onOpenEmail?: (threadId?: string) => void } & React.ComponentProps function formatEventTime(ts: string): string { @@ -450,7 +449,6 @@ export function SidebarContentPanel({ recordingMeetingSource, onToggleMeetingRecording, onOpenBgTasks, - isEmailOpen = false, onOpenEmail, ...props }: SidebarContentPanelProps) { @@ -465,7 +463,6 @@ export function SidebarContentPanel({ const { billing } = useBilling(isRowboatConnected) const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen - const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen const handleRowboatLogin = useCallback(async () => { try { @@ -528,26 +525,9 @@ export function SidebarContentPanel({ {/* Top spacer to clear the traffic lights + fixed toggle row */}
- {/* Quick action buttons */} -
- {onOpenEmail && ( - - )} -
+ \s*$/.exec(from) + if (match) return match[1].trim() + return from +} + +function formatEmailTime(value: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.round(diffMs / 60000) + if (diffMin < 1) return 'now' + if (diffMin < 60) return `${diffMin}m` + const sameDay = date.toDateString() === now.toDateString() + if (sameDay) return `${Math.round(diffMin / 60)}h` + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Yest' + if (diffMs < 7 * 24 * 60 * 60 * 1000) return date.toLocaleDateString([], { weekday: 'short' }) + if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: '2-digit' }) +} + +function EmailSidebarSection({ + onOpenEmailView, +}: { + onOpenEmailView?: (threadId?: string) => void +}) { + const [threads, setThreads] = useState([]) + + const load = useCallback(async () => { + try { + const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 }) + const unread = result.threads + .filter((t) => t.unread === true) + .slice(0, 3) + .map((t) => ({ + threadId: t.threadId, + subject: t.subject ?? '(No subject)', + from: t.from ?? '', + date: t.date ?? '', + })) + setThreads(unread) + } catch (err) { + console.error('Failed to load important emails:', err) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + let timeout: ReturnType | null = null + const scheduleReload = () => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { timeout = null; void load() }, 500) + } + const matches = (p: string | undefined) => + typeof p === 'string' && (p === 'gmail_sync' || p.startsWith('gmail_sync/')) + const cleanup = window.ipc.on('workspace:didChange', (event) => { + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + if (matches(event.path)) scheduleReload() + break + case 'moved': + if (matches(event.from) || matches(event.to)) scheduleReload() + break + case 'bulkChanged': + if (!event.paths || event.paths.some(matches)) scheduleReload() + break + } + }) + return () => { + if (timeout) clearTimeout(timeout) + cleanup() + } + }, [load]) + + return ( + +
+ Email +
+ + + {threads.map((t) => ( + + onOpenEmailView?.(t.threadId)} className="gap-2"> + + + {formatEmailFrom(t.from)} + ยท {t.subject} + + {t.date && ( + + {formatEmailTime(t.date)} + + )} + + + ))} + {onOpenEmailView && ( + + onOpenEmailView()}> + + View all + + + )} + + +
+ ) +} + function MeetingsSidebarSection({ onOpenMeetingsView, recordingState,