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
This commit is contained in:
arkml 2026-05-18 21:46:26 +05:30 committed by GitHub
parent af618155e1
commit 7dcf8eea70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 3139 additions and 72 deletions

View file

@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.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 { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js'; import { API_URL } from '@x/core/dist/config/env.js';
@ -482,6 +483,20 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => { 'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts); 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) => { 'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor); return mcpCore.listTools(args.serverName, args.cursor);
}, },

View file

@ -58,6 +58,877 @@
background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); 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 { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);

View file

@ -24,6 +24,7 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { LiveNotesView } from '@/components/live-notes-view'; import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view'; import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
Conversation, Conversation,
@ -178,6 +179,7 @@ const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
const EMAIL_TAB_PATH = '__rowboat_email__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) => 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 isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
const isBgTasksTabPath = (path: string) => path === BG_TASKS_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 isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => { const getSuggestedTopicTargetFolder = (category?: string) => {
@ -557,6 +560,7 @@ type ViewState =
| { type: 'task'; name: string } | { type: 'task'; name: string }
| { type: 'suggested-topics' } | { type: 'suggested-topics' }
| { type: 'live-notes' } | { type: 'live-notes' }
| { type: 'email' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean { function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false if (a.type !== b.type) return false
@ -710,11 +714,13 @@ function App() {
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
const [isEmailOpen, setIsEmailOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ const [expandedFrom, setExpandedFrom] = useState<{
path: string | null path: string | null
graph: boolean graph: boolean
suggestedTopics: boolean suggestedTopics: boolean
liveNotes: boolean liveNotes: boolean
email: boolean
} | null>(null) } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -1041,6 +1047,7 @@ function App() {
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isLiveNotesTabPath(tab.path)) return 'Live notes'
if (isBgTasksTabPath(tab.path)) return 'Background tasks' 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 === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2753,7 +2760,7 @@ function App() {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
@ -2762,7 +2769,7 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay]) }, [fileTabs, dismissBrowserOverlay])
@ -2781,32 +2788,43 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isSuggestedTopicsTabPath(tab.path)) { if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isLiveNotesTabPath(tab.path)) { if (isLiveNotesTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
return return
} }
if (isEmailTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
return
}
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => { const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId) 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) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -2829,7 +2847,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
@ -2843,21 +2861,30 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) { } else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) { } else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -2888,12 +2915,13 @@ function App() {
dismissBrowserOverlay() dismissBrowserOverlay()
handleNewChat() handleNewChat()
// Left-pane "new chat" should always open full chat view. // Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
email: isEmailOpen,
}) })
} else { } else {
setExpandedFrom(null) setExpandedFrom(null)
@ -2902,8 +2930,8 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen]) }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context. // Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => { const handleNewChatTabInSidebar = useCallback(() => {
@ -3035,12 +3063,13 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => { const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return // 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({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
email: isEmailOpen,
}) })
} }
dismissBrowserOverlay() dismissBrowserOverlay()
@ -3048,27 +3077,35 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => { const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) { if (expandedFrom) {
if (expandedFrom.graph) { if (expandedFrom.graph) {
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.suggestedTopics) { } else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.liveNotes) { } else if (expandedFrom.liveNotes) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (expandedFrom.email) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else if (expandedFrom.path) { } else if (expandedFrom.path) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(expandedFrom.path) setSelectedPath(expandedFrom.path)
} }
setExpandedFrom(null) setExpandedFrom(null)
@ -3078,12 +3115,13 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => { const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isEmailOpen) return { type: 'email' }
if (isLiveNotesOpen) return { type: 'live-notes' } if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath } if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' } if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId } 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 appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1] const last = stack[stack.length - 1]
@ -3162,12 +3200,37 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [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(() => { const openBgTasksView = useCallback(() => {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
@ -3184,7 +3247,7 @@ function App() {
// visible in the middle pane. // visible in the middle pane.
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files. // Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file. // Only exit chat-only maximize, because that would hide the selected file.
@ -3199,7 +3262,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -3212,7 +3275,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
@ -3225,7 +3288,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
ensureSuggestedTopicsFileTab() ensureSuggestedTopicsFileTab()
return return
case 'live-notes': case 'live-notes':
@ -3236,9 +3299,24 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
ensureLiveNotesFileTab() ensureLiveNotesFileTab()
return 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': case 'chat':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3247,7 +3325,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -3255,7 +3333,7 @@ function App() {
} }
return return
} }
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureEmailFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -3577,7 +3655,7 @@ function App() {
}, []) }, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view // 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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') { if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3650,11 +3728,11 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => { const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey const mod = e.metaKey || e.ctrlKey
if (!mod) return 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 const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane) ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left' : '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 const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH ? GRAPH_TAB_PATH
: isSuggestedTopicsOpen : isSuggestedTopicsOpen
@ -3663,6 +3741,8 @@ function App() {
? LIVE_NOTES_TAB_PATH ? LIVE_NOTES_TAB_PATH
: isBgTasksOpen : isBgTasksOpen
? BG_TASKS_TAB_PATH ? BG_TASKS_TAB_PATH
: isEmailOpen
? EMAIL_TAB_PATH
: selectedPath : selectedPath
const targetFileTabId = activeFileTabId ?? ( const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath selectedKnowledgePath
@ -3717,7 +3797,7 @@ function App() {
} }
document.addEventListener('keydown', handleTabKeyDown) document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('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') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { 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) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -3868,14 +3948,14 @@ function App() {
}, },
openGraph: () => { openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view. // 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) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
openBases: () => { openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4471,7 +4551,7 @@ function App() {
const selectedTask = selectedBackgroundTask const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask) ? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null : 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 isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => { const openMarkdownTabs = React.useMemo(() => {
@ -4488,7 +4568,7 @@ function App() {
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => { <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 }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
} }
}}> }}>
@ -4521,7 +4601,7 @@ function App() {
onNewChat: handleNewChatTab, onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive() cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
} }
@ -4532,7 +4612,7 @@ function App() {
return return
} }
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. // 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)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad) loadRun(runIdToLoad)
return return
@ -4556,14 +4636,14 @@ function App() {
} else { } else {
// Only one tab, reset it to new chat // Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }]) setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
handleNewChat() handleNewChat()
} else { } else {
void navigateToView({ type: 'chat', runId: null }) void navigateToView({ type: 'chat', runId: null })
} }
} }
} else if (runId === runIdToDelete) { } 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)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat() handleNewChat()
} else { } else {
@ -4597,6 +4677,8 @@ function App() {
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
isBgTasksOpen={isBgTasksOpen} isBgTasksOpen={isBgTasksOpen}
onOpenBgTasks={openBgTasksView} onOpenBgTasks={openBgTasksView}
isEmailOpen={isEmailOpen}
onOpenEmail={openEmailView}
/> />
<SidebarInset <SidebarInset
className={cn( className={cn(
@ -4616,7 +4698,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -4624,7 +4706,7 @@ function App() {
getTabId={(t) => t.id} getTabId={(t) => t.id}
onSwitchTab={switchFileTab} onSwitchTab={switchFileTab}
onCloseTab={closeFileTab} 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 <TabBar
@ -4677,7 +4759,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent> <TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4692,7 +4774,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4707,7 +4789,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent> <TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4760,6 +4842,10 @@ function App() {
}} }}
/> />
</div> </div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView />
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? ( ) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView <BasesView

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ import {
Lightbulb, Lightbulb,
ListChecks, ListChecks,
LoaderIcon, LoaderIcon,
Mail,
Settings, Settings,
Square, Square,
Trash2, Trash2,
@ -230,6 +231,8 @@ type SidebarContentPanelProps = {
onOpenLiveNotes?: () => void onOpenLiveNotes?: () => void
isBgTasksOpen?: boolean isBgTasksOpen?: boolean
onOpenBgTasks?: () => void onOpenBgTasks?: () => void
isEmailOpen?: boolean
onOpenEmail?: () => void
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [ const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -492,6 +495,8 @@ export function SidebarContentPanel({
onOpenLiveNotes, onOpenLiveNotes,
isBgTasksOpen = false, isBgTasksOpen = false,
onOpenBgTasks, onOpenBgTasks,
isEmailOpen = false,
onOpenEmail,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection() const { activeSection, setActiveSection } = useSidebarSection()
@ -509,6 +514,7 @@ export function SidebarContentPanel({
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => { const handleRowboatLogin = useCallback(async () => {
try { try {
@ -697,6 +703,21 @@ export function SidebarContentPanel({
<span>Background tasks</span> <span>Background tasks</span>
</button> </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 && ( {onOpenLiveNotes && (
<button <button
type="button" type="button"

View file

@ -0,0 +1,250 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { generateObject } from 'ai';
import { google } from 'googleapis';
import type { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { createProvider } from '../models/models.js';
import {
getDefaultModelAndProvider,
getKgModel,
resolveProviderConfig,
} from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import type { GmailThreadSnapshot } from './sync_gmail.js';
const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md');
const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync');
const CALENDAR_LOOKAHEAD_DAYS = 7;
const MAX_CALENDAR_EVENTS = 25;
function readEmailStyleGuide(): string | null {
try {
const raw = fs.readFileSync(STYLE_GUIDE_PATH, 'utf-8').trim();
return raw || null;
} catch {
return null;
}
}
interface CalendarSlice {
summary: string;
startIso: string;
endIso?: string;
}
function readUpcomingCalendar(): CalendarSlice[] {
if (!fs.existsSync(CALENDAR_DIR)) return [];
const now = Date.now();
const cutoff = now + CALENDAR_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
const out: CalendarSlice[] = [];
let names: string[];
try {
names = fs.readdirSync(CALENDAR_DIR);
} catch {
return [];
}
for (const name of names) {
if (!name.endsWith('.json')) continue;
try {
const raw = fs.readFileSync(path.join(CALENDAR_DIR, name), 'utf-8');
const ev = JSON.parse(raw) as {
summary?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
status?: string;
};
if (ev.status === 'cancelled') continue;
const startStr = ev.start?.dateTime ?? ev.start?.date;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (Number.isNaN(startMs)) continue;
if (startMs < now || startMs > cutoff) continue;
out.push({
summary: ev.summary || '(no title)',
startIso: startStr,
endIso: ev.end?.dateTime ?? ev.end?.date,
});
} catch {
// skip malformed
}
}
out.sort((a, b) => Date.parse(a.startIso) - Date.parse(b.startIso));
return out.slice(0, MAX_CALENDAR_EVENTS);
}
function formatCalendar(events: CalendarSlice[]): string {
if (events.length === 0) return '(no upcoming events)';
return events.map((e) => {
const end = e.endIso ? ` ${e.endIso}` : '';
return `- ${e.startIso}${end}: ${e.summary}`;
}).join('\n');
}
let cachedUserEmail: string | null = null;
export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
if (cachedUserEmail) return cachedUserEmail;
try {
const gmailClient = google.gmail({ version: 'v1', auth });
const res = await gmailClient.users.getProfile({ userId: 'me' });
if (res.data.emailAddress) {
cachedUserEmail = res.data.emailAddress.toLowerCase();
return cachedUserEmail;
}
} catch (err) {
console.warn('[Email classifier] getProfile failed:', err);
}
return null;
}
export interface Classification {
importance: 'important' | 'other';
summary?: string;
draftResponse?: string;
}
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
# Importance
Decide if the thread is "important" or "other":
- important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions).
- other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach.
# Summary (important only)
When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied. Omit when "other".
# Draft response (important only)
When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is.
Apply the user's email-style guide (when provided below) match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
- Propose 2-3 specific time windows from genuinely free slots, or
- Confirm/decline a specific time the sender proposed, based on calendar conflicts.
Use the same timezone the user appears to operate in (inferable from their previous messages or calendar events).
Omit the draft when:
- importance is "other"
- the thread is purely informational and doesn't ask for a reply
- the latest message is from the user (they already replied; no draft needed)
- you can't write a meaningful reply without information you don't have (don't fabricate)
Be decisive pick exactly one importance label. Do not hedge.`;
function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
if (!userEmail) return false;
const needle = userEmail.toLowerCase();
return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle));
}
function buildPrompt(
snapshot: GmailThreadSnapshot,
userEmail: string | null,
styleGuide: string | null,
calendar: CalendarSlice[],
): string {
const lines: string[] = [];
if (userEmail) {
lines.push(`# Your identity`);
lines.push(`The user's own email is ${userEmail}. You write as this person when drafting replies.`);
lines.push('');
}
if (styleGuide) {
lines.push(`# Email style guide`);
lines.push(styleGuide);
lines.push('');
}
lines.push(`# User's upcoming calendar (next ${CALENDAR_LOOKAHEAD_DAYS} days)`);
lines.push(formatCalendar(calendar));
lines.push('');
lines.push(`# Thread to classify`);
lines.push(`Subject: ${snapshot.subject || '(no subject)'}`);
lines.push(`Message count: ${snapshot.messages.length}`);
lines.push('');
for (let i = 0; i < snapshot.messages.length; i += 1) {
const msg = snapshot.messages[i];
const isLast = i === snapshot.messages.length - 1;
lines.push(`## Message ${i + 1}${isLast ? ' (latest)' : ''}`);
lines.push(`From: ${msg.from || 'unknown'}`);
if (msg.to) lines.push(`To: ${msg.to}`);
if (msg.date) lines.push(`Date: ${msg.date}`);
const body = (msg.body || '').replace(/\s+/g, ' ').slice(0, isLast ? 2000 : 600).trim();
if (body) {
lines.push('');
lines.push(body);
}
lines.push('');
}
return lines.join('\n');
}
export interface ClassifyOptions {
skipDraft?: boolean;
}
export async function classifyThread(
snapshot: GmailThreadSnapshot,
userEmail: string | null,
options: ClassifyOptions = {},
): Promise<Classification> {
if (userReplied(snapshot, userEmail)) {
return { importance: 'important' };
}
try {
const styleGuide = readEmailStyleGuide();
const calendar = readUpcomingCalendar();
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
const model = createProvider(config).languageModel(modelId);
const systemPrompt = options.skipDraft
? `${SYSTEM_PROMPT}\n\n# Skip the draft\n\nThe user already has their own draft in progress for this thread — DO NOT generate a draftResponse. Always omit the draftResponse field.`
: SYSTEM_PROMPT;
const result = await generateObject({
model,
system: systemPrompt,
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
schema: ClassificationSchema,
});
captureLlmUsage({
useCase: 'knowledge_sync',
subUseCase: 'email_classifier',
model: modelId,
provider,
usage: result.usage,
});
const out: Classification = { importance: result.object.importance };
if (result.object.importance === 'important') {
if (result.object.summary) out.summary = result.object.summary;
if (!options.skipDraft && result.object.draftResponse) out.draftResponse = result.object.draftResponse;
}
return out;
} catch (err) {
console.warn(`[Email classifier] LLM call failed for thread ${snapshot.threadId}:`, err);
return { importance: 'important' };
}
}

View file

@ -134,7 +134,7 @@ async function publishCalendarSyncEvent(
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const LOOKBACK_DAYS = 7; const LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [ const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly',

View file

@ -8,19 +8,114 @@ import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { createEvent } from '../events/producer.js'; import { createEvent } from '../events/producer.js';
import { classifyThread, getUserEmail } from './classify_thread.js';
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LEGACY_CACHE_DIR = path.join(SYNC_DIR, 'cache');
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
(function migrateLegacyCacheDir() {
try {
if (fs.existsSync(LEGACY_CACHE_DIR) && !fs.existsSync(CACHE_DIR)) {
fs.renameSync(LEGACY_CACHE_DIR, CACHE_DIR);
console.log(`[Gmail] Migrated cache from ${LEGACY_CACHE_DIR}${CACHE_DIR}`);
}
} catch (err) {
console.warn('[Gmail] Cache directory migration failed:', err);
}
})();
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const MAX_THREADS_IN_DIGEST = 10; const MAX_THREADS_IN_DIGEST = 10;
const nhm = new NodeHtmlMarkdown(); const nhm = new NodeHtmlMarkdown();
interface SnapshotCacheEntry {
historyId: string;
fetchedAt: string;
snapshot: GmailThreadSnapshot;
}
function cachePath(threadId: string): string {
return path.join(CACHE_DIR, `${encodeURIComponent(threadId)}.json`);
}
function readCachedSnapshot(threadId: string): SnapshotCacheEntry | null {
try {
const raw = fs.readFileSync(cachePath(threadId), 'utf-8');
return JSON.parse(raw) as SnapshotCacheEntry;
} catch {
return null;
}
}
function writeCachedSnapshot(threadId: string, historyId: string, snapshot: GmailThreadSnapshot): void {
try {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
const entry: SnapshotCacheEntry = {
historyId,
fetchedAt: new Date().toISOString(),
snapshot,
};
fs.writeFileSync(cachePath(threadId), JSON.stringify(entry), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] write failed for ${threadId}:`, err);
}
}
export function saveMessageBodyHeight(threadId: string, messageId: string, height: number): void {
const cached = readCachedSnapshot(threadId);
if (!cached) return;
const message = cached.snapshot.messages.find((m) => m.id === messageId);
if (!message) return;
if (message.bodyHeight === height) return;
message.bodyHeight = height;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] height write failed for ${threadId}/${messageId}:`, err);
}
}
interface SyncedThread { interface SyncedThread {
threadId: string; threadId: string;
markdown: string; markdown: string;
} }
export interface GmailThreadSnapshot {
threadId: string;
threadUrl: string;
summary?: string;
subject?: string;
from?: string;
to?: string;
date?: string;
latest_email?: string;
past_summary?: string;
unread?: boolean;
importance?: 'important' | 'other';
draft_response?: string;
gmail_draft?: string;
messages: Array<{
id?: string;
from?: string;
to?: string;
cc?: string;
date?: string;
subject?: string;
body?: string;
bodyHtml?: string;
unread?: boolean;
bodyHeight?: number;
attachments?: Array<{
filename: string;
mimeType?: string;
sizeBytes?: number;
savedPath: string;
}>;
}>;
}
function summarizeGmailSync(threads: SyncedThread[]): string { function summarizeGmailSync(threads: SyncedThread[]): string {
const lines: string[] = [ const lines: string[] = [
`# Gmail sync update`, `# Gmail sync update`,
@ -93,35 +188,416 @@ function decodeBase64(data: string): string {
return Buffer.from(data, 'base64').toString('utf-8'); return Buffer.from(data, 'base64').toString('utf-8');
} }
function extractBodyParts(payload: gmail.Schema$MessagePart): { text: string; html: string } {
const out = { text: '', html: '' };
const walk = (part: gmail.Schema$MessagePart): void => {
const mime = part.mimeType || '';
if (mime === 'text/html' && part.body?.data) {
if (!out.html) out.html = decodeBase64(part.body.data);
return;
}
if (mime === 'text/plain' && part.body?.data) {
if (!out.text) out.text = decodeBase64(part.body.data);
return;
}
if (part.parts) {
for (const sub of part.parts) walk(sub);
}
};
walk(payload);
return out;
}
function getBody(payload: gmail.Schema$MessagePart): string { function getBody(payload: gmail.Schema$MessagePart): string {
let body = ""; const { text, html } = extractBodyParts(payload);
if (payload.parts) { if (html) {
for (const part of payload.parts) { const md = nhm.translate(html);
if (part.mimeType === 'text/plain' && part.body && part.body.data) { return md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
const text = decodeBase64(part.body.data); }
// Strip quoted lines if (text) {
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>')); return text.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
body += cleanLines.join('\n'); }
} else if (part.mimeType === 'text/html' && part.body && part.body.data) { return '';
const html = decodeBase64(part.body.data); }
const md = nhm.translate(html);
// Simple quote stripping for MD interface ExtractedAttachment {
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>')); filename: string;
body += cleanLines.join('\n'); mimeType?: string;
} else if (part.parts) { sizeBytes?: number;
body += getBody(part); savedPath: string;
}
/**
* Walk a message MIME tree and collect "real" attachments parts with a
* filename + attachmentId, excluding cid-referenced inline images (those
* already get baked into bodyHtml as data URLs).
*
* Returns workspace-relative paths matching the convention used by
* saveAttachment / processThread, so the renderer can hand them to
* shell.openPath via the existing IPC.
*/
function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart): ExtractedAttachment[] {
const out: ExtractedAttachment[] = [];
const walk = (part: gmail.Schema$MessagePart): void => {
const filename = part.filename;
const attId = part.body?.attachmentId;
if (filename && attId) {
// Exclude only true inline images (image/* with a Content-ID, which
// get baked into bodyHtml as data URLs by inlineCidImages). Other
// parts with Content-ID — PDFs, .log files, .ics, etc. — are real
// attachments; Gmail just stamps Content-ID on most parts.
const cid = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const mime = part.mimeType || '';
const isInlineImage = !!cid && mime.startsWith('image/');
if (!isInlineImage) {
const safeName = `${msgId}_${cleanFilename(filename)}`;
out.push({
filename,
mimeType: part.mimeType ?? undefined,
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
savedPath: `gmail_sync/attachments/${safeName}`,
});
} }
} }
} else if (payload.body && payload.body.data) { if (part.parts) for (const sub of part.parts) walk(sub);
const data = decodeBase64(payload.body.data); };
if (payload.mimeType === 'text/html') { walk(payload);
const md = nhm.translate(data); return out;
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); }
} else {
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); async function inlineCidImages(
gmailClient: gmail.Gmail,
messageId: string,
payload: gmail.Schema$MessagePart,
html: string,
): Promise<string> {
if (!/src\s*=\s*["']?cid:/i.test(html)) return html;
const inlineParts: Array<{ contentId: string; mimeType: string; attachmentId: string }> = [];
const collect = (part: gmail.Schema$MessagePart): void => {
const cidHeader = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const attachmentId = part.body?.attachmentId;
const mime = part.mimeType || '';
if (cidHeader && attachmentId && mime.startsWith('image/')) {
inlineParts.push({
contentId: cidHeader.replace(/^<|>$/g, '').trim(),
mimeType: mime,
attachmentId,
});
}
if (part.parts) for (const sub of part.parts) collect(sub);
};
collect(payload);
if (inlineParts.length === 0) return html;
const dataUrls = new Map<string, string>();
await Promise.all(inlineParts.map(async (part) => {
try {
const res = await gmailClient.users.messages.attachments.get({
userId: 'me',
messageId,
id: part.attachmentId,
});
const b64 = res.data.data;
if (!b64) return;
// Gmail returns base64url; data URLs need standard base64
const normalized = b64.replace(/-/g, '+').replace(/_/g, '/');
dataUrls.set(part.contentId, `data:${part.mimeType};base64,${normalized}`);
} catch (err) {
console.warn(`[Gmail] inline image fetch failed for ${part.contentId}:`, err);
}
}));
let rewritten = html;
for (const [cid, url] of dataUrls) {
const escaped = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
rewritten = rewritten.replace(new RegExp(`cid:${escaped}`, 'gi'), url);
}
return rewritten;
}
function normalizeBody(body: string): string {
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
}
export interface RecentThreadInfo {
threadId: string;
historyId: string;
snippet?: string;
}
export type InboxSection = 'important' | 'other';
export interface InboxPageOptions {
section: InboxSection;
cursor?: string;
limit?: number;
}
export interface InboxPageResult {
threads: GmailThreadSnapshot[];
nextCursor: string | null;
}
interface IndexedEntry {
threadId: string;
dateMs: number;
snapshot: GmailThreadSnapshot;
}
function snapshotImportance(s: GmailThreadSnapshot): InboxSection {
return s.importance === 'other' ? 'other' : 'important';
}
function snapshotDateMs(s: GmailThreadSnapshot): number {
const latest = s.messages[s.messages.length - 1];
const raw = latest?.date || s.date;
if (!raw) return 0;
const ms = Date.parse(raw);
return Number.isFinite(ms) ? ms : 0;
}
function parseCursor(cursor: string | undefined): { dateMs: number; threadId: string } | null {
if (!cursor) return null;
const idx = cursor.indexOf('|');
if (idx < 0) return null;
const dateMs = Number(cursor.slice(0, idx));
const threadId = cursor.slice(idx + 1);
if (!Number.isFinite(dateMs) || !threadId) return null;
return { dateMs, threadId };
}
function encodeCursor(entry: { dateMs: number; threadId: string }): string {
return `${entry.dateMs}|${entry.threadId}`;
}
export function listImportantThreads(opts: { cursor?: string; limit?: number } = {}): InboxPageResult {
return listInboxPage({ section: 'important', ...opts });
}
export function listEverythingElseThreads(opts: { cursor?: string; limit?: number } = {}): InboxPageResult {
return listInboxPage({ section: 'other', ...opts });
}
export function listInboxPage(opts: InboxPageOptions): InboxPageResult {
const limit = Math.max(1, Math.min(100, opts.limit ?? 25));
const cursor = parseCursor(opts.cursor);
if (!fs.existsSync(CACHE_DIR)) return { threads: [], nextCursor: null };
let names: string[];
try {
names = fs.readdirSync(CACHE_DIR);
} catch {
return { threads: [], nextCursor: null };
}
const entries: IndexedEntry[] = [];
for (const name of names) {
if (!name.endsWith('.json')) continue;
const filePath = path.join(CACHE_DIR, name);
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const wrapper = JSON.parse(raw) as SnapshotCacheEntry;
const snapshot = wrapper.snapshot;
if (!snapshot) continue;
if (snapshotImportance(snapshot) !== opts.section) continue;
entries.push({
threadId: snapshot.threadId,
dateMs: snapshotDateMs(snapshot),
snapshot,
});
} catch (err) {
console.warn(`[Inbox lists] read failed for ${name}:`, err);
} }
} }
return body;
// Newest first, threadId asc as tiebreak.
entries.sort((a, b) => {
if (b.dateMs !== a.dateMs) return b.dateMs - a.dateMs;
return a.threadId < b.threadId ? -1 : 1;
});
let startIdx = 0;
if (cursor) {
startIdx = entries.findIndex((e) => {
if (e.dateMs < cursor.dateMs) return true;
if (e.dateMs === cursor.dateMs && e.threadId > cursor.threadId) return true;
return false;
});
if (startIdx < 0) startIdx = entries.length;
}
const slice = entries.slice(startIdx, startIdx + limit);
const hasMore = startIdx + slice.length < entries.length;
const last = slice[slice.length - 1];
return {
threads: slice.map((e) => e.snapshot),
nextCursor: hasMore && last ? encodeCursor({ dateMs: last.dateMs, threadId: last.threadId }) : null,
};
}
export async function listRecentThreadIds(daysAgo: number = 2): Promise<RecentThreadInfo[]> {
const auth = await GoogleClientFactory.getClient();
if (!auth) {
throw new Error('Gmail is not connected.');
}
const gmailClient = google.gmail({ version: 'v1', auth });
const since = new Date();
since.setDate(since.getDate() - daysAgo);
const dateQuery = since.toISOString().split('T')[0].replace(/-/g, '/');
const results: RecentThreadInfo[] = [];
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
q: `after:${dateQuery}`,
pageToken,
});
const threads = res.data.threads || [];
for (const thread of threads) {
if (thread.id && thread.historyId) {
results.push({
threadId: thread.id,
historyId: thread.historyId,
snippet: thread.snippet || undefined,
});
}
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return results;
}
/**
* Build a GmailThreadSnapshot from an already-fetched threads.get response,
* classify it, and write to inbox_lists/. Called by the background sync
* (processThread) the only path that materializes snapshots.
*
* Returns null when the thread has no visible (non-draft) messages
* those shouldn't show up in the inbox.
*/
async function buildAndCacheSnapshot(
threadId: string,
threadData: gmail.Schema$Thread,
gmailClient: gmail.Gmail,
auth: OAuth2Client,
): Promise<GmailThreadSnapshot | null> {
const messages = threadData.messages;
if (!messages || messages.length === 0) return null;
const cached = readCachedSnapshot(threadId);
// Short-circuit: if the thread hasn't changed since we last classified it,
// skip the rebuild + classifier. Saves the cid-image fetches and one LLM
// call per unchanged thread (matters most during fullSync after a
// historyId expiry, where the whole window is re-walked).
// We require `importance` to be present too — pre-classifier cache files
// would otherwise stick around forever uncategorised.
if (
threadData.historyId &&
cached &&
cached.historyId === threadData.historyId &&
cached.snapshot.importance
) {
return cached.snapshot;
}
const heightCarryover = new Map<string, number>();
if (cached) {
for (const m of cached.snapshot.messages) {
if (m.id && typeof m.bodyHeight === 'number') heightCarryover.set(m.id, m.bodyHeight);
}
}
const parsed = await Promise.all(messages.map(async (msg) => {
const headers = msg.payload?.headers || [];
const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' };
const body = msg.payload ? normalizeBody(getBody(msg.payload)) : '';
let bodyHtml: string | undefined;
if (parts.html && msg.payload && msg.id) {
try {
bodyHtml = await inlineCidImages(gmailClient, msg.id, msg.payload, parts.html);
} catch (err) {
console.warn(`[Gmail] inline image embed failed for message ${msg.id}:`, err);
bodyHtml = parts.html;
}
}
const isDraft = msg.labelIds?.includes('DRAFT') ?? false;
const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload) : [];
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,
bodyHtml,
unread: msg.labelIds?.includes('UNREAD') ?? false,
bodyHeight: msg.id ? heightCarryover.get(msg.id) : undefined,
messageIdHeader: headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined,
attachments: attachments.length > 0 ? attachments : undefined,
isDraft,
};
}));
const sentMessages = parsed.filter((m) => !m.isDraft);
const draftMessages = parsed.filter((m) => m.isDraft);
const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest);
const latestDraftBody = draftMessages.length > 0
? draftMessages[draftMessages.length - 1]!.body.trim()
: '';
if (visibleMessages.length === 0) return null;
const latest = visibleMessages[visibleMessages.length - 1]!;
const earlier = visibleMessages.slice(0, -1);
const earlierSummary = earlier
.map((msg) => {
const date = msg.date ? ` (${msg.date})` : '';
const body = msg.body.replace(/\s+/g, ' ').slice(0, 500).trim();
return `${msg.from}${date}: ${body}`;
})
.filter(Boolean)
.join('\n\n');
const snapshot: GmailThreadSnapshot = {
threadId,
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
subject: latest.subject || visibleMessages[0]?.subject,
from: latest.from,
to: latest.to,
date: latest.date,
latest_email: latest.body,
past_summary: earlierSummary || undefined,
unread: visibleMessages.some((m) => m.unread),
messages: visibleMessages,
gmail_draft: latestDraftBody || undefined,
};
try {
const userEmail = await getUserEmail(auth);
const skipDraft = latestDraftBody.length > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) snapshot.draft_response = classification.draftResponse;
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
} }
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> { async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
@ -225,6 +701,14 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`); console.log(`Synced Thread: ${subject} (${threadId})`);
// Also build + cache the rich snapshot for the inbox view.
// Reuses the threads.get response — no extra API call.
try {
await buildAndCacheSnapshot(threadId, thread, gmail, auth);
} catch (err) {
console.warn(`[Gmail] Inbox snapshot build failed for ${threadId}:`, err);
}
return { threadId, markdown: mdContent }; return { threadId, markdown: mdContent };
} catch (error) { } catch (error) {
@ -233,6 +717,46 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
} }
} }
/**
* After a sync cycle, prune inbox_lists/ entries for threadIds that are
* no longer in INBOX (archived/trashed elsewhere). Single threads.list call,
* keeps the cache in lock-step with Gmail's INBOX label.
*/
async function pruneInboxCache(auth: OAuth2Client): Promise<void> {
if (!fs.existsSync(CACHE_DIR)) return;
try {
const gmailClient = google.gmail({ version: 'v1', auth });
const inInbox = new Set<string>();
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
labelIds: ['INBOX'],
maxResults: 500,
pageToken,
});
for (const t of res.data.threads || []) {
if (t.id) inInbox.add(t.id);
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
for (const name of fs.readdirSync(CACHE_DIR)) {
if (!name.endsWith('.json')) continue;
const threadId = decodeURIComponent(name.replace(/\.json$/, ''));
if (!inInbox.has(threadId)) {
try {
fs.rmSync(path.join(CACHE_DIR, name), { force: true });
} catch (err) {
console.warn(`[Gmail] prune failed for ${threadId}:`, err);
}
}
}
} catch (err) {
console.warn('[Gmail] pruneInboxCache failed:', err);
}
}
function loadState(stateFile: string): { historyId?: string; last_sync?: string } { function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
if (fs.existsSync(stateFile)) { if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
@ -515,6 +1039,10 @@ async function performSync() {
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} }
// Keep inbox_lists/ in lock-step with Gmail's INBOX label —
// remove cache files for threads that were archived/trashed elsewhere.
await pruneInboxCache(auth);
console.log("Sync completed."); console.log("Sync completed.");
} catch (error) { } catch (error) {
console.error("Error during sync:", error); console.error("Error during sync:", error);

View file

@ -88,12 +88,13 @@ export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
export const EmailBlockSchema = z.object({ export const EmailBlockSchema = z.object({
threadId: z.string().optional(), threadId: z.string().optional(),
threadUrl: z.string().url().optional(),
summary: z.string().optional(), summary: z.string().optional(),
subject: z.string().optional(), subject: z.string().optional(),
from: z.string().optional(), from: z.string().optional(),
to: z.string().optional(), to: z.string().optional(),
date: z.string().optional(), date: z.string().optional(),
latest_email: z.string(), latest_email: z.string().optional(),
past_summary: z.string().optional(), past_summary: z.string().optional(),
draft_response: z.string().optional(), draft_response: z.string().optional(),
response_mode: z.enum(['inline', 'assistant', 'both']).optional(), response_mode: z.enum(['inline', 'assistant', 'both']).optional(),
@ -101,6 +102,42 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>; export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const GmailAttachmentSchema = z.object({
filename: z.string(),
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative().optional(),
savedPath: z.string(),
});
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
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(),
bodyHtml: z.string().optional(),
unread: z.boolean().optional(),
bodyHeight: z.number().int().positive().optional(),
attachments: z.array(GmailAttachmentSchema).optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
export const GmailThreadSchema = EmailBlockSchema.extend({
threadId: z.string(),
threadUrl: z.string().url(),
unread: z.boolean().optional(),
importance: z.enum(['important', 'other']).optional(),
gmail_draft: z.string().optional(),
messages: z.array(GmailThreadMessageSchema),
});
export type GmailThread = z.infer<typeof GmailThreadSchema>;
export const EmailsBlockSchema = z.object({ export const EmailsBlockSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
emails: z.array(EmailBlockSchema), emails: z.array(EmailBlockSchema),

View file

@ -18,6 +18,7 @@ import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js'; import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js'; import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js'; import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -123,6 +124,38 @@ const ipcSchemas = {
req: WorkspaceChangeEvent, req: WorkspaceChangeEvent,
res: z.null(), res: z.null(),
}, },
'gmail:getImportant': {
req: z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
nextCursor: z.string().nullable(),
}),
},
'gmail:getEverythingElse': {
req: z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
nextCursor: z.string().nullable(),
}),
},
'gmail:triggerSync': {
req: z.object({}),
res: z.object({}),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),
messageId: z.string().min(1),
height: z.number().int().positive(),
}),
res: z.object({}),
},
'mcp:listTools': { 'mcp:listTools': {
req: z.object({ req: z.object({
serverName: z.string(), serverName: z.string(),