From 7dcf8eea705000f720283214583cf9938be766ae Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Mon, 18 May 2026 21:46:26 +0530 Subject: [PATCH] Email page (#561) * email view * render html emails * match unread and read status * move to accordian * faster loads * iframe mounted across toggle and cached height * prefetch on hover * fix iframe caching * split inbox * email processing agent * summary * rich text * email drafts * add pagination, watcher and separation from gmail sync * fix first load issue * handle drafts * send button opens the thread * simplify renderer and fix flickering issue * remove rended driven email path * support attachments in incoming emails * fix white background as well as dark mode --- apps/x/apps/main/src/ipc.ts | 15 + apps/x/apps/renderer/src/App.css | 871 ++++++++++++ apps/x/apps/renderer/src/App.tsx | 176 ++- .../renderer/src/components/email-view.tsx | 1226 +++++++++++++++++ .../src/components/sidebar-content.tsx | 21 + .../core/src/knowledge/classify_thread.ts | 250 ++++ .../core/src/knowledge/sync_calendar.ts | 2 +- .../packages/core/src/knowledge/sync_gmail.ts | 578 +++++++- apps/x/packages/shared/src/blocks.ts | 39 +- apps/x/packages/shared/src/ipc.ts | 33 + 10 files changed, 3139 insertions(+), 72 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/email-view.tsx create mode 100644 apps/x/packages/core/src/knowledge/classify_thread.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b4728739..78f8b55e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -482,6 +483,20 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, + 'gmail:getImportant': async (_event, args) => { + return listImportantThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:getEverythingElse': async (_event, args) => { + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:triggerSync': async () => { + triggerGmailSync(); + return {}; + }, + 'gmail:saveMessageHeight': async (_event, args) => { + saveMessageBodyHeight(args.threadId, args.messageId, args.height); + return {}; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 5c1eabb2..bccafd34 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -58,6 +58,877 @@ background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); } +.gmail-shell { + --gm-bg: #0f0f12; + --gm-bg-card: #131317; + --gm-bg-input: #1c1c20; + --gm-bg-row-hover: #16161a; + --gm-bg-row-selected: #1a1620; + --gm-bg-row-selected-hover: #1d1825; + --gm-bg-iframe: #fafafa; + --gm-bg-pill: #1c1c20; + --gm-bg-pill-hover: #232328; + --gm-text: #e4e4e7; + --gm-text-strong: #fafafa; + --gm-text-muted: #71717a; + --gm-text-faint: #52525b; + --gm-text-body: #d4d4d8; + --gm-border: #1f1f24; + --gm-border-strong: #2e2e35; + --gm-accent: #a78bfa; + --gm-accent-hover: #b9a6ff; + --gm-accent-glow: rgba(167, 139, 250, 0.45); + --gm-accent-fg: #18181b; + --gm-icon-hover-bg: #1f1f24; + --gm-placeholder: #52525b; + + display: flex; + height: 100%; + min-height: 0; + width: 100%; + overflow: hidden; + background: var(--gm-bg); + color: var(--gm-text); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-feature-settings: "ss01", "cv11"; +} + +.light .gmail-shell { + --gm-bg: #ffffff; + --gm-bg-card: #ffffff; + --gm-bg-input: #f4f4f7; + --gm-bg-row-hover: #f7f7f9; + --gm-bg-row-selected: #f5f0ff; + --gm-bg-row-selected-hover: #ece4ff; + --gm-bg-iframe: #ffffff; + --gm-bg-pill: #ffffff; + --gm-bg-pill-hover: #f4f4f7; + --gm-text: #27272a; + --gm-text-strong: #09090b; + --gm-text-muted: #71717a; + --gm-text-faint: #a1a1aa; + --gm-text-body: #3f3f46; + --gm-border: #e4e4e7; + --gm-border-strong: #d4d4d8; + --gm-accent: #7c3aed; + --gm-accent-hover: #6d28d9; + --gm-accent-glow: rgba(124, 58, 237, 0.3); + --gm-accent-fg: #ffffff; + --gm-icon-hover-bg: #f4f4f7; + --gm-placeholder: #a1a1aa; +} + +.gmail-main { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + padding: 0; +} + +.gmail-topbar { + display: flex; + align-items: center; + gap: 12px; + height: 52px; + padding: 0 20px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-search { + display: flex; + align-items: center; + gap: 10px; + width: min(520px, 100%); + height: 32px; + padding: 0 12px; + border-radius: 8px; + background: var(--gm-bg-input); + color: var(--gm-text-muted); +} + +.gmail-search input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font-size: 13px; + letter-spacing: 0.01em; +} + +.gmail-search input::placeholder { + color: var(--gm-placeholder); +} + +.gmail-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-icon-button:hover { + background: var(--gm-icon-hover-bg); + color: var(--gm-text); +} + +.gmail-list { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + flex: 1; + overflow: auto; + background: var(--gm-bg); + border: none; + border-radius: 0; +} + +.gmail-row-group { + display: flex; + flex-direction: column; +} + +.gmail-list-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + justify-content: space-between; + height: 32px; + padding: 0 24px; + align-items: center; + background: var(--gm-bg); + border-bottom: 1px solid var(--gm-border); + color: var(--gm-text-faint); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.gmail-section { + display: flex; + flex-direction: column; +} + +.gmail-section + .gmail-section { + margin-top: 28px; +} + +.gmail-section-sentinel { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + color: var(--gm-text-faint); +} + +.gmail-row { + display: grid; + grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; + align-items: center; + gap: 16px; + width: 100%; + min-height: 40px; + padding: 0 24px; + border: none; + background: transparent; + color: var(--gm-text-muted); + text-align: left; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease; +} + +.gmail-row:hover { + background: var(--gm-bg-row-hover); + box-shadow: none; +} + +.gmail-row-selected { + background: var(--gm-bg-row-selected); + box-shadow: inset 2px 0 0 var(--gm-accent); +} + +.gmail-row-selected:hover { + background: var(--gm-bg-row-selected-hover); +} + +.gmail-row-unread { + color: var(--gm-text); +} + +.gmail-row-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; +} + +.gmail-row-unread .gmail-row-dot { + background: var(--gm-accent); + box-shadow: 0 0 8px var(--gm-accent-glow); +} + +.gmail-row-sender, +.gmail-row-content strong, +.gmail-row-date { + font-size: 13px; + font-weight: 400; + letter-spacing: -0.005em; +} + +.gmail-row-unread .gmail-row-sender, +.gmail-row-unread .gmail-row-content strong { + font-weight: 600; + color: var(--gm-text-strong); +} + +.gmail-row-unread .gmail-row-date { + color: var(--gm-text); +} + +.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: 8px; + color: var(--gm-text-faint); + font-size: 13px; +} + +.gmail-row-content strong { + flex-shrink: 0; + color: inherit; + font-weight: 400; +} + +.gmail-row-date { + justify-self: end; + color: var(--gm-text-faint); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.gmail-detail { + display: flex; + min-width: 0; + flex-direction: column; + background: transparent; +} + +.gmail-detail-inline { + background: var(--gm-bg-card); + border-top: 1px solid var(--gm-border); + border-bottom: 1px solid var(--gm-border); + box-shadow: inset 2px 0 0 var(--gm-accent); +} + +.gmail-detail-hidden { + display: none; +} + +.gmail-detail-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 48px; + padding: 0 24px; + border-bottom: 1px solid var(--gm-border); + background: transparent; +} + +.gmail-thread-subject-inline { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--gm-text-strong); + font-size: 15px; + font-weight: 500; + letter-spacing: -0.01em; +} + +.gmail-thread-body { + padding: 20px 24px 28px; + background: transparent; +} + +.gmail-thread-summary { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-thread-summary-label { + display: block; + margin-bottom: 6px; + color: var(--gm-text-faint); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gmail-thread-summary-text { + display: block; + color: var(--gm-text); + font-size: 13px; + line-height: 1.55; +} + +.gmail-message-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.gmail-message { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 12px; + padding: 12px 0; + border-top: 1px solid var(--gm-border); +} + +.gmail-message:first-child { + border-top: 0; + padding-top: 4px; +} + +.gmail-message-header { + display: block; + width: 100%; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.gmail-message-snippet { + margin-top: 2px; + color: var(--gm-text-muted); + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.gmail-message:not(.gmail-message-expanded) .gmail-message-header:hover .gmail-message-from strong { + color: var(--gm-accent); +} + +.gmail-message-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: #fafafa; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.02em; +} + +.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: 13px; + font-weight: 600; + color: var(--gm-text-strong); + letter-spacing: -0.005em; +} + +.gmail-message-from span, +.gmail-message-to, +.gmail-message-date { + color: var(--gm-text-muted); + font-size: 12px; +} + +.gmail-message-date { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.gmail-message-iframe { + display: block; + width: 100%; + max-width: 820px; + margin-top: 12px; + border: 0; + background: var(--gm-bg-iframe); + border-radius: 6px; +} + +.gmail-message-iframe-adaptive { + background: var(--gm-bg-card); +} + +.gmail-message-plain { + max-width: 820px; + margin-top: 12px; + padding: 10px 14px; + background: var(--gm-bg-iframe); + border-radius: 6px; + color: var(--gm-text); +} + +.gmail-message-pre { + margin: 0; + font: 14px/1.6 Arial, sans-serif; + white-space: pre-wrap; + word-wrap: break-word; +} + +.gmail-message-pre-quoted { + margin-top: 12px; + color: var(--gm-text-muted); +} + +.gmail-quote-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + height: 22px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-pill); + color: var(--gm-text-muted); + font: inherit; + font-size: 12px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-quote-toggle:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); + border-color: var(--gm-border-strong); +} + +.gmail-quote-toggle[aria-expanded="true"] { + background: var(--gm-bg-row-selected); + color: var(--gm-accent); + border-color: var(--gm-accent); +} + +.gmail-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + max-width: 820px; +} + +.gmail-attachment { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 320px; + padding: 6px 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.gmail-attachment:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-attachment-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 500; +} + +.gmail-attachment-size { + flex-shrink: 0; + color: var(--gm-text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.gmail-thread-actions { + display: flex; + gap: 8px; + margin: 20px 0 12px 40px; +} + +.gmail-thread-actions button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 14px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text-body); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.gmail-thread-actions button:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-border-strong); +} + +.gmail-compose-card { + max-width: 720px; + margin-left: 40px; + border: 1px solid var(--gm-border-strong); + border-radius: 8px; + overflow: hidden; + background: var(--gm-bg-card); +} + +.gmail-compose-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + background: var(--gm-bg-input); + color: var(--gm-text-body); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.gmail-compose-header button, +.gmail-compose-link { + border: none; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; +} + +.gmail-compose-line { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-bottom: 1px solid var(--gm-border); + color: var(--gm-text-muted); + font-size: 12px; +} + +.gmail-compose-line input { + min-width: 0; + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; +} + +.gmail-compose-toolbar { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + justify-content: center; +} + +.gmail-compose-link-popover { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--gm-border); + background: var(--gm-bg-input); +} + +.gmail-compose-link-popover input { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-card); + color: var(--gm-text); + font: inherit; + font-size: 12px; + outline: none; +} + +.gmail-compose-link-popover input:focus { + border-color: var(--gm-accent); +} + +.gmail-compose-link-popover button { + height: 26px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.gmail-compose-link-popover button:hover { + background: var(--gm-bg-pill-hover); +} + +.gmail-compose-link-popover-apply { + background: var(--gm-accent) !important; + border-color: var(--gm-accent) !important; + color: var(--gm-accent-fg) !important; + font-weight: 600; +} + +.gmail-compose-link-popover-apply:hover { + background: var(--gm-accent-hover) !important; + border-color: var(--gm-accent-hover) !important; +} + +.gmail-compose-tool { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-compose-tool:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text); +} + +.gmail-compose-tool.is-active { + background: var(--gm-bg-pill-hover); + color: var(--gm-accent); +} + +.gmail-compose-tool-sep { + display: inline-block; + width: 1px; + height: 18px; + margin: 0 6px; + background: var(--gm-border-strong); +} + +.gmail-compose-editor { + display: block; + width: 100%; + max-height: 360px; + overflow-y: auto; +} + +.gmail-compose-content { + outline: none; + min-height: 120px; + padding: 12px; + background: transparent; + color: var(--gm-text); + font: 13px/1.55 inherit; +} + +.gmail-compose-content p { + margin: 0; +} + +.gmail-compose-content p + p, +.gmail-compose-content p + ul, +.gmail-compose-content p + ol, +.gmail-compose-content p + blockquote { + margin-top: 8px; +} + +.gmail-compose-content ul, +.gmail-compose-content ol { + margin: 0; + padding-left: 22px; +} + +.gmail-compose-content ul { + list-style: disc; +} + +.gmail-compose-content ol { + list-style: decimal; +} + +.gmail-compose-content li { + margin: 2px 0; +} + +.gmail-compose-content li > p { + margin: 0; +} + +.gmail-compose-content blockquote { + margin: 4px 0; + padding-left: 12px; + border-left: 2px solid var(--gm-border-strong); + color: var(--gm-text-muted); +} + +.gmail-compose-content a { + color: var(--gm-accent); + text-decoration: underline; +} + +.gmail-compose-content code { + padding: 1px 4px; + border-radius: 3px; + background: var(--gm-bg-pill-hover); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.gmail-compose-content p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--gm-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.gmail-compose-actions { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-top: 1px solid var(--gm-border); +} + +.gmail-compose-actions-primary { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.gmail-refine-button { + display: inline-flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + height: 30px; + padding: 0 14px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.gmail-refine-button:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-send-button { + display: inline-flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + height: 30px; + padding: 0 14px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--gm-accent); + color: var(--gm-accent-fg); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.gmail-send-button:hover { + background: var(--gm-accent-hover); +} + +.gmail-empty-state { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + background: transparent; + color: var(--gm-text-faint); + font-size: 13px; +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7f76e2e9..de7744d5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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>({}) 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(() => { 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 ( { - 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} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? ( 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)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( + )} + {message.attachments && message.attachments.length > 0 && ( + + )} + + ) +} + +function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) { + const { resolvedTheme } = useTheme() + const iframeRef = useRef(null) + const observerRef = useRef(null) + const saveTimerRef = useRef | null>(null) + const lastSavedHeightRef = useRef(message.bodyHeight ?? 0) + const [height, setHeight] = useState(message.bodyHeight ?? 80) + const [hasQuote, setHasQuote] = useState(false) + const [showQuotes, setShowQuotes] = useState(false) + + const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml]) + const srcDoc = useMemo( + () => buildEmailDocument(message.bodyHtml!, { theme: resolvedTheme, adaptToTheme }), + [message.bodyHtml, resolvedTheme, adaptToTheme], + ) + + const handleLoad = useCallback(() => { + const iframe = iframeRef.current + const doc = iframe?.contentDocument + if (!doc?.body) return + setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]')) + const measure = () => { + // Measure off body only. documentElement.scrollHeight stretches to fill + // the iframe viewport, so once we size the iframe up (e.g. user expanded + // the quote) it never shrinks back when the body collapses. The body's + // own padding-bottom + last-child margin reset (see buildEmailDocument) + // already prevent under-reporting from collapsed bottom margins. + const next = Math.max(40, doc.body.scrollHeight, doc.body.offsetHeight) + setHeight((current) => (current === next ? current : next)) + if (!message.id) return + if (Math.abs(next - lastSavedHeightRef.current) < 4) return + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { + lastSavedHeightRef.current = next + void window.ipc.invoke('gmail:saveMessageHeight', { + threadId, + messageId: message.id!, + height: next, + }).catch(() => {}) + }, 500) + } + measure() + observerRef.current?.disconnect() + if (typeof ResizeObserver !== 'undefined') { + observerRef.current = new ResizeObserver(measure) + observerRef.current.observe(doc.body) + } + }, [message.id, threadId]) + + const toggleQuotes = useCallback(() => { + setShowQuotes((prev) => { + const next = !prev + const doc = iframeRef.current?.contentDocument + if (doc) doc.documentElement.dataset.showQuotes = next ? 'true' : '' + return next + }) + }, []) + + useEffect(() => () => { + observerRef.current?.disconnect() + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + }, []) + + return ( + <> +