email in main sidebar

This commit is contained in:
Arjun 2026-05-19 20:14:09 +05:30
parent 1a3e5a4e2e
commit 9e36ff2fa1
3 changed files with 156 additions and 28 deletions

View file

@ -775,6 +775,8 @@ function App() {
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(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()
}}
/>
<SidebarInset
className={cn(
@ -5304,7 +5309,7 @@ function App() {
</div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView />
<EmailView initialThreadId={emailInitialThreadId} threadIdVersion={emailThreadIdVersion} />
</div>
) : isWorkspaceOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">

View file

@ -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<SectionState>(() => clearLoadingFlag(persistedImportant))
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(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<string | null>(null)
const [query, setQuery] = useState('')

View file

@ -200,8 +200,7 @@ type SidebarContentPanelProps = {
recordingMeetingSource?: string | null
onToggleMeetingRecording?: () => void
onOpenBgTasks?: () => void
isEmailOpen?: boolean
onOpenEmail?: () => void
onOpenEmail?: (threadId?: string) => void
} & React.ComponentProps<typeof Sidebar>
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({
<SidebarHeader className="titlebar-drag-region">
{/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" />
{/* Quick action buttons */}
<div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
{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>
)}
</div>
</SidebarHeader>
<SidebarContent>
<EmailSidebarSection onOpenEmailView={onOpenEmail} />
<MeetingsSidebarSection
onOpenMeetingsView={onOpenMeetings}
recordingState={meetingRecordingState ?? 'idle'}
@ -1192,6 +1172,133 @@ function formatMeetingTime(event: UpcomingMeeting): string {
return event.start.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
}
type SidebarEmailThread = {
threadId: string
subject: string
from: string
date: string
}
function formatEmailFrom(from: string): string {
const match = /^\s*"?([^"<]+?)"?\s*<.+>\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<SidebarEmailThread[]>([])
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<SidebarEmailThread>((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<typeof setTimeout> | 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 (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Email
</div>
<SidebarGroupContent>
<SidebarMenu>
{threads.map((t) => (
<SidebarMenuItem key={t.threadId}>
<SidebarMenuButton onClick={() => onOpenEmailView?.(t.threadId)} className="gap-2">
<Mail className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm">
{formatEmailFrom(t.from)}
<span className="text-muted-foreground"> · {t.subject}</span>
</span>
{t.date && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatEmailTime(t.date)}
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
{onOpenEmailView && (
<SidebarMenuItem>
<SidebarMenuButton onClick={() => onOpenEmailView()}>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
function MeetingsSidebarSection({
onOpenMeetingsView,
recordingState,