add Gmail contacts autocomplete to compose box (#607)

Adds a gmail:searchContacts IPC channel backed by two indices: a
SENT-label API-backed index (gmail_sent_contacts) for full historical
coverage of people you've actually emailed, and a local-snapshot
fallback (gmail_contacts) used until the SENT sync finishes on first
launch. Both indices warm at startup so the first keystroke in the
recipient box is instant. Renderer wires the suggestions into the
to/cc/bcc fields in email-view with styled chips.

Co-authored-by: arkml <6592213+arkml@users.noreply.github.com>
This commit is contained in:
Harshvardhan Vatsa 2026-06-10 14:58:13 +05:30 committed by GitHub
parent 0aec665220
commit c48ef5ac0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1056 additions and 4 deletions

View file

@ -53,6 +53,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@ -444,6 +446,13 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
// for full historical coverage of people you've actually emailed.
warmContactIndex();
warmSentContacts();
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@ -521,6 +530,22 @@ export function setupIpcHandlers() {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};
},
'gmail:searchContacts': async (_event, args) => {
const query = args?.query ?? '';
const limit = args?.limit;
const excludeEmails = args?.excludeEmails;
// Primary source: people you've actually sent mail to (Gmail SENT label,
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
// — used only when the SENT index hasn't been populated yet (very first
// launch, before the background sync finishes).
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
if (sent.length > 0) {
return { contacts: sent };
}
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
return { contacts: fallback };
},
'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor);
},

View file

@ -800,6 +800,108 @@
gap: 4px;
flex: 1;
min-width: 0;
position: relative;
}
.gmail-recipient-suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 30;
margin: 0;
padding: 6px;
list-style: none;
width: max-content;
min-width: 280px;
max-width: min(440px, 100%);
background: var(--gm-bg-elevated, #1e1e1e);
border: 1px solid var(--gm-border);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 12px 32px rgba(0, 0, 0, 0.36);
max-height: 296px;
overflow-y: auto;
overscroll-behavior: contain;
transform-origin: top left;
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes gmail-recipient-suggestions-in {
from {
opacity: 0;
transform: translateY(-2px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.gmail-recipient-suggestion {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--gm-text);
cursor: pointer;
transition: background-color 80ms linear;
}
.gmail-recipient-suggestion:hover {
background: var(--gm-bg-pill-hover);
}
.gmail-recipient-suggestion.is-active {
background: rgba(99, 142, 255, 0.18);
}
.gmail-recipient-suggestion-avatar {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.gmail-recipient-suggestion-text {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
line-height: 1.25;
}
.gmail-recipient-suggestion-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.gmail-recipient-suggestion-email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
color: var(--gm-text-muted);
margin-top: 1px;
}
.gmail-recipient-suggestion-match {
background: transparent;
color: inherit;
font-weight: 700;
padding: 0;
}
.gmail-recipient-chip {

View file

@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
)
}
type ContactSuggestion = {
name: string
email: string
}
function formatContactToken(c: ContactSuggestion): string {
return c.name ? `${c.name} <${c.email}>` : c.email
}
// Stable hue per email so the avatar circle keeps a consistent color.
function contactHue(email: string): number {
let h = 0
for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0
return h % 360
}
function contactInitial(c: ContactSuggestion): string {
const src = (c.name || c.email).trim()
return (src[0] || '?').toUpperCase()
}
// Renders a string with the matched substring wrapped in <mark>.
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>
const lower = text.toLowerCase()
const q = query.toLowerCase()
const idx = lower.indexOf(q)
if (idx < 0) return <>{text}</>
return (
<>
{text.slice(0, idx)}
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
)
}
function RecipientField({
label,
value,
@ -626,34 +663,123 @@ function RecipientField({
trailing?: React.ReactNode
}) {
const [draft, setDraft] = useState('')
const [suggestions, setSuggestions] = useState<ContactSuggestion[]>([])
const [activeIndex, setActiveIndex] = useState(0)
const [isFocused, setIsFocused] = useState(false)
const [queryShown, setQueryShown] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const fieldRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const queryTokenRef = useRef(0)
useEffect(() => {
if (autoFocus) inputRef.current?.focus()
}, [autoFocus])
const excludeEmails = useMemo(
() => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean),
[value],
)
// Debounced contact search — only runs when the user has actually typed
// something. An empty draft (including the post-pick reset) closes the menu.
useEffect(() => {
const trimmed = draft.trim()
if (!isFocused || !trimmed) {
queryTokenRef.current++
setSuggestions([])
return
}
const token = ++queryTokenRef.current
const timer = window.setTimeout(async () => {
try {
const result = (await window.ipc.invoke('gmail:searchContacts', {
query: draft,
limit: 8,
excludeEmails,
})) as { contacts?: ContactSuggestion[] } | undefined
if (token !== queryTokenRef.current) return
setSuggestions(result?.contacts ?? [])
setQueryShown(trimmed)
setActiveIndex(0)
} catch {
if (token !== queryTokenRef.current) return
setSuggestions([])
}
}, 60)
return () => window.clearTimeout(timer)
}, [draft, isFocused, excludeEmails])
// Keep the active row scrolled into view during keyboard navigation.
useEffect(() => {
const list = listRef.current
if (!list) return
const node = list.children[activeIndex] as HTMLElement | undefined
node?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, suggestions])
const commit = (raw: string) => {
const additions = splitAddresses(raw)
if (additions.length === 0) return
onChange(dedupeRecipients([...value, ...additions], new Set()))
setDraft('')
setSuggestions([])
}
const pickSuggestion = (c: ContactSuggestion) => {
commit(formatContactToken(c))
// Keep focus in the input so the user can keep typing more recipients.
inputRef.current?.focus()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
const hasSuggestions = suggestions.length > 0
if (event.key === 'ArrowDown' && hasSuggestions) {
event.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
return
}
if (event.key === 'ArrowUp' && hasSuggestions) {
event.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
return
}
if (event.key === 'Escape' && hasSuggestions) {
event.preventDefault()
setSuggestions([])
return
}
if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) {
// Prefer the highlighted suggestion when one is present.
if (hasSuggestions) {
event.preventDefault()
pickSuggestion(suggestions[activeIndex])
return
}
if (event.key === 'Enter' && draft.trim()) {
event.preventDefault()
commit(draft)
return
}
}
if (event.key === ',' || event.key === ';') {
if (draft.trim()) {
event.preventDefault()
commit(draft)
}
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
return
}
if (event.key === 'Backspace' && !draft && value.length > 0) {
onChange(value.slice(0, -1))
}
}
const showSuggestions = isFocused && suggestions.length > 0
return (
<div className="gmail-recipient-row">
<span className="gmail-recipient-label">{label}</span>
<div className="gmail-recipient-field">
<div className="gmail-recipient-field" ref={fieldRef}>
{value.map((token, index) => (
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
@ -674,7 +800,16 @@ function RecipientField({
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
onBlur={() => { if (draft.trim()) commit(draft) }}
onFocus={() => setIsFocused(true)}
onBlur={() => {
// Defer so a mousedown on a suggestion can pick it before the menu closes.
window.setTimeout(() => {
setIsFocused(false)
if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) {
commit(draft)
}
}, 80)
}}
onPaste={(event) => {
const text = event.clipboardData.getData('text')
if (text && /[,;\n]/.test(text)) {
@ -683,6 +818,45 @@ function RecipientField({
}
}}
/>
{showSuggestions && (
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
{suggestions.map((c, idx) => {
const hue = contactHue(c.email)
return (
<li
key={c.email}
role="option"
aria-selected={idx === activeIndex}
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
onMouseDown={(event) => {
// Prevent input blur before click fires.
event.preventDefault()
pickSuggestion(c)
}}
onMouseEnter={() => setActiveIndex(idx)}
>
<span
className="gmail-recipient-suggestion-avatar"
style={{ background: `hsl(${hue}, 60%, 42%)` }}
aria-hidden="true"
>
{contactInitial(c)}
</span>
<span className="gmail-recipient-suggestion-text">
<span className="gmail-recipient-suggestion-name">
<HighlightedText text={c.name || c.email} query={queryShown} />
</span>
{c.name && (
<span className="gmail-recipient-suggestion-email">
<HighlightedText text={c.email} query={queryShown} />
</span>
)}
</span>
</li>
)
})}
</ul>
)}
</div>
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
</div>