mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +02:00
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:
parent
af618155e1
commit
7dcf8eea70
10 changed files with 3139 additions and 72 deletions
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
250
apps/x/packages/core/src/knowledge/classify_thread.ts
Normal file
250
apps/x/packages/core/src/knowledge/classify_thread.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue