From fcbcc137ca5af6c8dbd56b417aa5045b0cf54dc1 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:47:12 +0530 Subject: [PATCH 01/10] make meeting note name wrap --- apps/x/apps/renderer/src/components/meetings-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index ef9a5864..2ad04082 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -1058,7 +1058,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee From 80fef06da0ff2d0295a70018cf34e2c854b199cc Mon Sep 17 00:00:00 2001 From: gagan Date: Wed, 10 Jun 2026 14:50:45 +0530 Subject: [PATCH 02/10] feat: make meeting recording auto-stop when the meeting ends (#611) Detect silence from raw mic+system audio armed at recording start, add a quiet-meeting stop nudge, shorten the window once past the calendar end time, and stop instantly when the shared call window closes. --- .../src/hooks/useMeetingTranscription.ts | 134 ++++++++++++++---- 1 file changed, 109 insertions(+), 25 deletions(-) diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 50d89a57..2295b9c0 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -1,4 +1,5 @@ import { useCallback, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url'; import { useRowboatAccount } from '@/hooks/useRowboatAccount'; @@ -21,8 +22,23 @@ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS. // RMS threshold: system audio above this = "active" (speakers playing) const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005; -// Auto-stop after 2 minutes of silence (no transcript from Deepgram) -const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000; +// RMS threshold for "someone is talking" on either channel. Drives silence +// detection — kept a touch above the gate threshold so faint room noise on the +// mic doesn't read as speech and keep a finished recording alive. +const SPEECH_RMS_THRESHOLD = 0.01; + +// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER +// the mic or the system-audio channel (i.e. nobody — local or remote — talking). +// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop. +// - After SILENCE_BACKSTOP_MS we stop unconditionally. +// - Once past the linked calendar event's end time we use the shorter +// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a +// strong signal the meeting is actually over. +const SILENCE_NUDGE_MS = 2 * 60 * 1000; +const SILENCE_BACKSTOP_MS = 5 * 60 * 1000; +const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000; +// How often the silence checker runs. +const SILENCE_CHECK_INTERVAL_MS = 5 * 1000; // --------------------------------------------------------------------------- // Headphone detection @@ -119,7 +135,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const interimRef = useRef>(new Map()); const notePathRef = useRef(''); const writeTimerRef = useRef | null>(null); - const silenceTimerRef = useRef | null>(null); + // Silence detection: timestamp of the last speech-level audio on either + // channel, plus the interval that checks it. calendarEndMsRef holds the + // linked event's end time (null if none). + const lastAudioActivityRef = useRef(0); + const silenceCheckRef = useRef | null>(null); + const calendarEndMsRef = useRef(null); + const nudgeToastIdRef = useRef(null); const onAutoStopRef = useRef(onAutoStop); onAutoStopRef.current = onAutoStop; const dateRef = useRef(''); @@ -161,9 +183,13 @@ export function useMeetingTranscription(onAutoStop?: () => void) { clearTimeout(writeTimerRef.current); writeTimerRef.current = null; } - if (silenceTimerRef.current) { - clearTimeout(silenceTimerRef.current); - silenceTimerRef.current = null; + if (silenceCheckRef.current) { + clearInterval(silenceCheckRef.current); + silenceCheckRef.current = null; + } + if (nudgeToastIdRef.current !== null) { + toast.dismiss(nudgeToastIdRef.current); + nudgeToastIdRef.current = null; } if (processorRef.current) { processorRef.current.disconnect(); @@ -279,13 +305,6 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const transcript = data.channel.alternatives[0].transcript; if (!transcript) return; - // Reset silence auto-stop timer on any transcript - if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); - silenceTimerRef.current = setTimeout(() => { - console.log('[meeting] 2 minutes of silence — auto-stopping'); - onAutoStopRef.current?.(); - }, SILENCE_AUTO_STOP_MS); - const channelIndex = data.channel_index?.[0] ?? 0; const isMic = channelIndex === 0; @@ -325,6 +344,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const systemStream = systemResult.value; systemStreamRef.current = systemStream; + // If the shared source goes away (user closes the call window / clicks + // "Stop sharing"), the track fires "ended" — treat that as the meeting + // ending and stop. Our own cleanup() calls track.stop(), which does NOT + // fire "ended", so this won't double-trigger on a manual stop. + systemStream.getAudioTracks().forEach(track => { + track.addEventListener('ended', () => { + console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping'); + onAutoStopRef.current?.(); + }); + }); + // ----- Audio pipeline ----- const audioCtx = new AudioContext({ sampleRate: 16000 }); audioCtxRef.current = audioCtx; @@ -345,24 +375,33 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const micRaw = e.inputBuffer.getChannelData(0); const sysRaw = e.inputBuffer.getChannelData(1); + // RMS of each channel, computed once per frame and reused for + // silence detection and gating the mic in speaker mode. + let micSum = 0; + for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i]; + const micRms = Math.sqrt(micSum / micRaw.length); + let sysSum = 0; + for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i]; + const sysRms = Math.sqrt(sysSum / sysRaw.length); + + // Reset the silence clock whenever EITHER channel has speech-level + // audio. Uses the raw mic (pre-gating) so the user's own voice counts + // even in speaker mode where the outgoing mic gets muted. + if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) { + lastAudioActivityRef.current = Date.now(); + } + // Mode 1 (headphones): pass both streams through unmodified // Mode 2 (speakers): gate/mute mic when system audio is active let micOut: Float32Array; if (usingHeadphones) { micOut = micRaw; + } else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) { + // System audio is playing — mute mic to prevent bleed + micOut = new Float32Array(micRaw.length); // all zeros } else { - // Compute system audio RMS to detect activity - let sysSum = 0; - for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i]; - const sysRms = Math.sqrt(sysSum / sysRaw.length); - - if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) { - // System audio is playing — mute mic to prevent bleed - micOut = new Float32Array(micRaw.length); // all zeros - } else { - // System audio is silent — pass mic through - micOut = micRaw; - } + // System audio is silent — pass mic through + micOut = micRaw; } // Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM @@ -391,6 +430,12 @@ export function useMeetingTranscription(onAutoStop?: () => void) { const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`; notePathRef.current = notePath; calendarEventRef.current = calendarEvent; + + // Parse the linked event's end time (timed events only) so the silence + // window can shorten once the meeting is past its scheduled end. + const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN; + calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null; + const initialContent = formatTranscript([], dateStr, calendarEvent); await window.ipc.invoke('workspace:writeFile', { path: notePath, @@ -398,6 +443,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) { opts: { encoding: 'utf8', mkdirp: true }, }); + // Arm silence detection. Initialise the activity clock to "now" so the + // checker is live from the very start of recording — a session that + // never captures any audio still auto-stops at the backstop instead of + // running forever. + lastAudioActivityRef.current = Date.now(); + if (silenceCheckRef.current) clearInterval(silenceCheckRef.current); + silenceCheckRef.current = setInterval(() => { + const silentMs = Date.now() - lastAudioActivityRef.current; + const endMs = calendarEndMsRef.current; + const pastCalendarEnd = endMs != null && Date.now() > endMs; + const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS; + + if (silentMs >= hardStopMs) { + console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`); + onAutoStopRef.current?.(); + return; + } + + if (silentMs >= SILENCE_NUDGE_MS) { + // Ask once; the toast persists until dismissed or acted on. Past + // the scheduled end we skip straight to the hard stop above, so + // the nudge only ever shows for an in-progress meeting. + if (nudgeToastIdRef.current === null) { + nudgeToastIdRef.current = toast('Still in a meeting?', { + description: "It's been quiet for a couple of minutes.", + duration: Infinity, + action: { + label: 'Stop recording', + onClick: () => { onAutoStopRef.current?.(); }, + }, + }); + } + } else if (nudgeToastIdRef.current !== null) { + // Audio resumed before the backstop — retract the nudge. + toast.dismiss(nudgeToastIdRef.current); + nudgeToastIdRef.current = null; + } + }, SILENCE_CHECK_INTERVAL_MS); + setState('recording'); return notePath; }, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]); From 0aec6652207d597376c9b4520a10dcee2c6df055 Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:55:15 +0530 Subject: [PATCH 03/10] add pacman maker for Arch Linux packages (#604) Custom Electron Forge maker that wraps makepkg to produce a .pkg.tar.zst with /opt/, /usr/bin wrapper, .desktop entry, and hicolor icon. Only activates on Linux when makepkg is present, so other platforms are unaffected. --- apps/x/apps/main/forge.config.cjs | 15 +++ apps/x/apps/main/makers/maker-pacman.cjs | 134 +++++++++++++++++++++++ apps/x/apps/main/package.json | 1 + apps/x/pnpm-lock.yaml | 3 + 4 files changed, 153 insertions(+) create mode 100644 apps/x/apps/main/makers/maker-pacman.cjs diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7806f6cd..b6f15b66 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -86,6 +86,21 @@ module.exports = { } } }, + { + name: require.resolve('./makers/maker-pacman.cjs'), + platforms: ['linux'], + config: { + name: 'rowboat', + bin: 'rowboat', + executableName: 'rowboat', + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com', + license: 'Apache', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], + } + }, { name: '@electron-forge/maker-zip', platform: ["darwin", "win32", "linux"], diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs new file mode 100644 index 00000000..4cae1da9 --- /dev/null +++ b/apps/x/apps/main/makers/maker-pacman.cjs @@ -0,0 +1,134 @@ +// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages +// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host). +// +// CJS on purpose: forge.config.cjs require()s us. + +const path = require('path'); +const fs = require('fs'); +const { execSync } = require('child_process'); + +const MakerBase = require('@electron-forge/maker-base').default; + +const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' }; + +class MakerPacman extends MakerBase { + name = 'pacman'; + defaultPlatforms = ['linux']; + + isSupportedOnCurrentPlatform() { + if (process.platform !== 'linux') return false; + try { + execSync('command -v makepkg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + + async make({ dir, makeDir, targetArch, packageJSON, appName }) { + const pkgArch = ARCH_MAP[targetArch] || targetArch; + + const cfg = this.config || {}; + const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase(); + // pacman pkgver disallows '-'; map prerelease tags through. + const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_'); + const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"'); + const maintainer = cfg.maintainer || 'unknown'; + const homepage = cfg.homepage || packageJSON.homepage || ''; + const license = cfg.license || 'custom'; + const bin = cfg.bin || pkgName; + const execName = cfg.executableName || appName || pkgName; + const mimeTypes = cfg.mimeType || []; + const depends = cfg.depends || []; + const iconSrc = cfg.icon; + + const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch)); + await this.ensureDirectory(outDir); + + // Clean prior contents so makepkg starts fresh each run. + for (const f of fs.readdirSync(outDir)) { + fs.rmSync(path.join(outDir, f), { recursive: true, force: true }); + } + + // Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs). + fs.writeFileSync( + path.join(outDir, bin), + `#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`, + { mode: 0o755 }, + ); + + const desktop = [ + '[Desktop Entry]', + `Name=${appName || pkgName}`, + `Comment=${pkgDesc}`, + `Exec=${bin} %U`, + `Icon=${pkgName}`, + 'Type=Application', + 'Categories=Utility;', + 'Terminal=false', + mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null, + '', + ].filter(Boolean).join('\n'); + fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop); + + const sources = [bin, `${pkgName}.desktop`]; + let iconInstall = ''; + if (iconSrc && fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png')); + sources.push('icon.png'); + iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`; + } + + const sumsLine = sources.map(() => "'SKIP'").join(' '); + const sourceLine = sources.map((s) => `'${s}'`).join(' '); + const dependsLine = depends.map((d) => `'${d}'`).join(' '); + // Embed the packager output dir as a bash-safe literal. + const appDirEscaped = dir.replace(/'/g, `'\\''`); + + const pkgbuild = `# Maintainer: ${maintainer} +# Auto-generated by maker-pacman.cjs — do not edit by hand. +pkgname=${pkgName} +pkgver=${pkgVersion} +pkgrel=1 +pkgdesc="${pkgDesc}" +arch=('${pkgArch}') +url="${homepage}" +license=('${license}') +depends=(${dependsLine}) +options=('!strip' '!debug') +source=(${sourceLine}) +sha256sums=(${sumsLine}) + +_appdir='${appDirEscaped}' + +package() { + install -dm755 "$pkgdir/opt/$pkgname" + cp -a "$_appdir/." "$pkgdir/opt/$pkgname/" + + # Electron's sandbox helper needs setuid root for the namespace sandbox. + if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + fi + + install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}" + install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop" +${iconInstall} +} +`; + fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild); + + execSync('makepkg -f --noconfirm --nodeps', { + cwd: outDir, + stdio: 'inherit', + env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch }, + }); + + return fs + .readdirSync(outDir) + .filter((f) => f.endsWith('.pkg.tar.zst')) + .map((f) => path.join(outDir, f)); + } +} + +module.exports = MakerPacman; +module.exports.default = MakerPacman; diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 3330c3c0..b6edd064 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@electron-forge/cli": "^7.10.2", + "@electron-forge/maker-base": "^7.11.1", "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", "@electron-forge/maker-rpm": "^7.11.1", diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index c4e5a8d5..55ec19f2 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@electron-forge/cli': specifier: ^7.10.2 version: 7.11.1(encoding@0.1.13)(esbuild@0.24.2) + '@electron-forge/maker-base': + specifier: ^7.11.1 + version: 7.11.1 '@electron-forge/maker-deb': specifier: ^7.11.1 version: 7.11.1 From c48ef5ac0c2287ca57bc6aa423fa36a5d7d36cfa Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:58:13 +0530 Subject: [PATCH 04/10] 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> --- apps/x/apps/main/src/ipc.ts | 25 ++ apps/x/apps/renderer/src/App.css | 102 +++++ .../renderer/src/components/email-view.tsx | 182 +++++++- .../core/src/knowledge/gmail_contacts.ts | 348 ++++++++++++++++ .../core/src/knowledge/gmail_sent_contacts.ts | 388 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 15 + 6 files changed, 1056 insertions(+), 4 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/gmail_contacts.ts create mode 100644 apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e5d407f8..52d314f5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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); }, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..02cfd7bd 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -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 { diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index dea0561e..86f79fa7 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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 . +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)} + {text.slice(idx, idx + q.length)} + {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([]) + const [activeIndex, setActiveIndex] = useState(0) + const [isFocused, setIsFocused] = useState(false) + const [queryShown, setQueryShown] = useState('') const inputRef = useRef(null) + const fieldRef = useRef(null) + const listRef = useRef(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) => { - 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 (
{label} -
+
{value.map((token, index) => ( {recipientLabel(token)} @@ -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 && ( +
    + {suggestions.map((c, idx) => { + const hue = contactHue(c.email) + return ( +
  • { + // Prevent input blur before click fires. + event.preventDefault() + pickSuggestion(c) + }} + onMouseEnter={() => setActiveIndex(idx)} + > + + + + + + {c.name && ( + + + + )} + +
  • + ) + })} +
+ )}
{trailing &&
{trailing}
}
diff --git a/apps/x/packages/core/src/knowledge/gmail_contacts.ts b/apps/x/packages/core/src/knowledge/gmail_contacts.ts new file mode 100644 index 00000000..30842d4b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/gmail_contacts.ts @@ -0,0 +1,348 @@ +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import type { GmailThreadSnapshot } from './sync_gmail.js'; +import { getAccountEmail } from './sync_gmail.js'; + +const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); +const INDEX_TTL_MS = 5 * 60 * 1000; +const RECENCY_HALFLIFE_DAYS = 60; +const READ_CONCURRENCY = 16; + +export interface Contact { + name: string; + email: string; + count: number; + lastSeenMs: number; +} + +interface IndexEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Map; +} + +let cachedIndex: Map | null = null; +let cachedAt = 0; +let pendingRebuild: Promise> | null = null; + +function parseAddressList(header: string): Array<{ name: string; email: string }> { + if (!header) return []; + const parts: string[] = []; + let buf = ''; + let inQuotes = false; + let inBrackets = 0; + for (const ch of header) { + if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes; + else if (ch === '<') inBrackets++; + else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1); + if (ch === ',' && !inQuotes && inBrackets === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf.trim()); + + const result: Array<{ name: string; email: string }> = []; + for (const part of parts) { + const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/); + if (angled) { + const name = angled[1].trim().replace(/^"|"$/g, '').trim(); + const email = angled[2].trim().toLowerCase(); + if (email.includes('@')) result.push({ name, email }); + } else if (part.includes('@')) { + result.push({ name: '', email: part.trim().toLowerCase() }); + } + } + return result; +} + +// Local-part aliases that are almost always automated/role addresses you don't +// compose a fresh message to. Matched as a whole segment of the local part +// (segments split on . _ - +). +const AUTOMATED_LOCAL_PARTS = new Set([ + 'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply', + 'notifications', 'notification', 'notify', + 'alerts', 'alert', 'updates', 'update', + 'news', 'newsletter', 'newsletters', + 'info', 'information', 'hello', 'hi', 'hey', + 'welcome', 'onboarding', 'getstarted', + 'team', 'marketing', 'promo', 'promos', 'promotions', + 'offer', 'offers', 'deals', 'deal', + 'accounts', 'account', 'billing', 'invoices', 'statements', 'statement', + 'learn', 'learning', 'courses', + 'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces', + 'automated', 'auto', 'autoconfirm', + 'support-bot', 'noticeboard', 'system', + 'contact', 'connect', + 'sender', 'broadcast', 'digest', 'campaign', 'campaigns', + 'support', 'service', 'help', 'helpdesk', 'feedback', + 'mailer', 'mailers', 'members', 'membership', + 'careers', 'jobs', 'recruit', 'recruiting', + 'tickets', 'orders', 'order', 'receipts', 'receipt', + 'applications', 'apply', 'admissions', + 'health', 'security', 'auth', +]); + +// Subdomain labels that flag a bulk/marketing infrastructure domain. +const AUTOMATED_SUBDOMAIN_LABELS = new Set([ + 'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta', + 'email', 'em', 'e', 'm', + 'news', 'newsletter', 'newsletters', + 'marketing', 'mkt', 'promo', 'promos', 'offers', + 'event', 'events', 'ecomm', 'commerce', + 'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates', + 'messaging', 'message', 'msg', + 'noreply', 'donotreply', + 'creators', 'partners', 'team', + 'info', 'welcome', 'hi', 'hello', + 'bounces', 'bounce', + 'reply', 'user', 'usr', 'auto', +]); + +// Specific bulk-mail provider domains (substring match on full domain). +const AUTOMATED_DOMAIN_KEYWORDS = [ + 'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid', + 'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail', + 'amazonses', 'sparkpost', 'sendinblue', 'brevo', + 'luma-mail', 'lumamail', + 'umusic-online', 'icloud-mail', +]; + +function localSegments(local: string): string[] { + return local.toLowerCase().split(/[._\-+]/).filter(Boolean); +} + +function isAutomatedAddress(email: string): boolean { + if (!email) return true; + const at = email.indexOf('@'); + if (at < 0) return true; + const local = email.slice(0, at).toLowerCase(); + const domain = email.slice(at + 1).toLowerCase(); + + // Plus-aliased reply bots: `reply+abc123@…` + if (/^reply\+/i.test(local)) return true; + + // Whole-segment local-part matches. + const segs = localSegments(local); + for (const s of segs) { + if (AUTOMATED_LOCAL_PARTS.has(s)) return true; + } + // Some senders pack noise into the local part with no separators + // (e.g. `hdfcbanksmartstatement`). Catch the common ones. + if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) { + return true; + } + + // Random-looking machine local parts: long, mostly hex/base32-ish. + if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) { + const digits = (local.match(/[0-9]/g) || []).length; + if (digits / local.length >= 0.25) return true; + } + + // Subdomain-label check (everything except the registrable last two labels). + const labels = domain.split('.'); + if (labels.length >= 3) { + const subs = labels.slice(0, -2); + for (const label of subs) { + if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true; + } + } + + // Provider keyword anywhere in the domain. + for (const kw of AUTOMATED_DOMAIN_KEYWORDS) { + if (domain.includes(kw)) return true; + } + + // Domain itself contains tell-tale tokens. + if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) { + return true; + } + + // Marketing-style TLD / second-level domain (e.g. bookmyshow.email, + // foo.marketing, bar.news). These domains exist almost exclusively for bulk. + const sld = labels[labels.length - 1]; + if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) { + return true; + } + + // Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` — + // local part equals the first label of the domain. Almost always a + // transactional/marketing sender. + if (labels.length >= 2 && local === labels[0]) { + return true; + } + + return false; +} + +function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map): void { + if (!snapshot?.messages) return; + for (const msg of snapshot.messages) { + const parsed = msg.date ? Date.parse(msg.date) : NaN; + const ts = Number.isFinite(parsed) ? parsed : 0; + const fromAddrs = msg.from ? parseAddressList(msg.from) : []; + const sentBySelf = fromAddrs.some((a) => a.email === selfEmail); + + // Collect candidate contacts. For outbound mail, take recipients (the + // people *you* chose to write to — highest signal). For inbound mail, + // take the sender, but only if it doesn't look like a no-reply bot. + const candidates: Array<{ name: string; email: string }> = []; + if (sentBySelf) { + for (const h of [msg.to, msg.cc].filter(Boolean) as string[]) { + candidates.push(...parseAddressList(h)); + } + } else { + for (const a of fromAddrs) candidates.push(a); + } + + for (const { name, email } of candidates) { + if (!email || email === selfEmail) continue; + if (isAutomatedAddress(email)) continue; + let entry = map.get(email); + if (!entry) { + entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() }; + map.set(email, entry); + } + // Sent-to addresses carry stronger signal than inbound senders. + entry.count += sentBySelf ? 3 : 1; + if (ts > entry.lastSeenMs) entry.lastSeenMs = ts; + if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1); + } + } +} + +async function rebuildIndex(): Promise> { + const map = new Map(); + if (!fs.existsSync(CACHE_DIR)) return map; + + // Without a self email we can't tell which messages were sent by the user, + // so the index stays empty until Gmail is connected. + const selfRaw = await getAccountEmail().catch(() => null); + if (!selfRaw) return map; + const selfEmail = selfRaw.trim().toLowerCase(); + + let names: string[]; + try { + names = await fsp.readdir(CACHE_DIR); + } catch { + return map; + } + + const files = names.filter((n) => n.endsWith('.json')); + // Cap concurrency so a huge inbox can't blow the FD table. + for (let i = 0; i < files.length; i += READ_CONCURRENCY) { + const slice = files.slice(i, i + READ_CONCURRENCY); + const chunks = await Promise.all( + slice.map(async (fname) => { + try { + return await fsp.readFile(path.join(CACHE_DIR, fname), 'utf-8'); + } catch { + return null; + } + }), + ); + for (const raw of chunks) { + if (!raw) continue; + try { + const wrapper = JSON.parse(raw) as { snapshot?: GmailThreadSnapshot }; + if (wrapper.snapshot) ingestSnapshot(wrapper.snapshot, selfEmail, map); + } catch { + continue; + } + } + } + + for (const entry of map.values()) { + let best = entry.name; + let bestN = 0; + for (const [n, c] of entry.nameCounts) { + if (c > bestN) { best = n; bestN = c; } + } + entry.name = best; + } + return map; +} + +async function getIndex(): Promise> { + const now = Date.now(); + const fresh = cachedIndex && now - cachedAt <= INDEX_TTL_MS; + if (fresh) return cachedIndex!; + + // Serve stale cache while a refresh runs in the background; only block when + // there's no cache at all. + if (!pendingRebuild) { + pendingRebuild = rebuildIndex().then((m) => { + cachedIndex = m; + cachedAt = Date.now(); + pendingRebuild = null; + return m; + }).catch((err) => { + pendingRebuild = null; + throw err; + }); + } + if (cachedIndex) return cachedIndex; + return pendingRebuild; +} + +export function invalidateContactIndex(): void { + cachedIndex = null; + cachedAt = 0; +} + +// Warm the cache eagerly so the first user keystroke doesn't pay the cost. +export function warmContactIndex(): void { + void getIndex().catch(() => {}); +} + +function score(entry: IndexEntry, nowMs: number): number { + const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24)); + const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS); + return entry.count * (0.5 + 0.5 * recency); +} + +function matchTier(q: string, entry: IndexEntry): number { + if (!q) return 3; + const name = entry.name.toLowerCase(); + const email = entry.email; + if (name && name.startsWith(q)) return 0; + if (email.startsWith(q)) return 1; + if (name && name.includes(' ' + q)) return 1; + if (name && name.includes(q)) return 2; + if (email.includes(q)) return 3; + return -1; +} + +export interface SearchOpts { + limit?: number; + excludeEmails?: string[]; +} + +export async function searchContacts(query: string, opts: SearchOpts = {}): Promise { + const q = query.trim().toLowerCase(); + const limit = Math.max(1, Math.min(50, opts.limit ?? 8)); + const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase())); + + const index = await getIndex(); + const nowMs = Date.now(); + const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = []; + for (const entry of index.values()) { + if (excluded.has(entry.email)) continue; + const tier = matchTier(q, entry); + if (tier < 0) continue; + matches.push({ entry, tier, s: score(entry, nowMs) }); + } + matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s)); + return matches.slice(0, limit).map(({ entry }) => ({ + name: entry.name, + email: entry.email, + count: entry.count, + lastSeenMs: entry.lastSeenMs, + })); +} diff --git a/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts b/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts new file mode 100644 index 00000000..15ccf65e --- /dev/null +++ b/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts @@ -0,0 +1,388 @@ +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import { google, gmail_v1 as gmail } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; +import { WorkDir } from '../config/config.js'; +import { GoogleClientFactory } from './google-client-factory.js'; +import { getUserEmail } from './classify_thread.js'; + +const STATE_FILE = path.join(WorkDir, 'contacts_sent.json'); +const RECENCY_HALFLIFE_DAYS = 60; +const HEADER_FETCH_CONCURRENCY = 8; +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +export interface Contact { + name: string; + email: string; + count: number; + lastSeenMs: number; +} + +interface StoredEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Record; +} + +interface StoredState { + version: 1; + historyId: string | null; + selfEmail: string | null; + lastFullSyncAt: number; + entries: StoredEntry[]; +} + +interface IndexEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Map; +} + +let cachedIndex: Map | null = null; +let lastRefreshAt = 0; +let pendingSync: Promise | null = null; + +// Parses an address-list header value, respecting quoted display names and +// angle brackets ("Last, First" , …). +function parseAddressList(header: string): Array<{ name: string; email: string }> { + if (!header) return []; + const parts: string[] = []; + let buf = ''; + let inQuotes = false; + let inBrackets = 0; + for (const ch of header) { + if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes; + else if (ch === '<') inBrackets++; + else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1); + if (ch === ',' && !inQuotes && inBrackets === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf.trim()); + + const out: Array<{ name: string; email: string }> = []; + for (const part of parts) { + const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/); + if (angled) { + const name = angled[1].trim().replace(/^"|"$/g, '').trim(); + const email = angled[2].trim().toLowerCase(); + if (email.includes('@')) out.push({ name, email }); + } else if (part.includes('@')) { + out.push({ name: '', email: part.trim().toLowerCase() }); + } + } + return out; +} + +function loadState(): StoredState | null { + try { + if (!fs.existsSync(STATE_FILE)) return null; + const raw = fs.readFileSync(STATE_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as StoredState; + if (parsed.version !== 1) return null; + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StoredState): Promise { + const tmp = STATE_FILE + '.tmp'; + await fsp.mkdir(path.dirname(STATE_FILE), { recursive: true }); + await fsp.writeFile(tmp, JSON.stringify(state), 'utf-8'); + await fsp.rename(tmp, STATE_FILE); +} + +function indexFromStored(state: StoredState): Map { + const map = new Map(); + for (const e of state.entries) { + map.set(e.email, { + name: e.name, + email: e.email, + count: e.count, + lastSeenMs: e.lastSeenMs, + nameCounts: new Map(Object.entries(e.nameCounts || {})), + }); + } + return map; +} + +function storedFromIndex(map: Map, historyId: string | null, selfEmail: string | null, lastFullSyncAt: number): StoredState { + const entries: StoredEntry[] = []; + for (const e of map.values()) { + entries.push({ + name: e.name, + email: e.email, + count: e.count, + lastSeenMs: e.lastSeenMs, + nameCounts: Object.fromEntries(e.nameCounts), + }); + } + return { version: 1, historyId, selfEmail, lastFullSyncAt, entries }; +} + +function promoteCanonicalNames(map: Map): void { + for (const entry of map.values()) { + let best = entry.name; + let bestN = 0; + for (const [n, c] of entry.nameCounts) { + if (c > bestN) { best = n; bestN = c; } + } + entry.name = best; + } +} + +// Pulls the To/Cc/Date headers for a single sent message and folds the parsed +// recipients into the index. +async function ingestMessage( + client: gmail.Gmail, + messageId: string, + selfEmail: string, + map: Map, +): Promise { + const res = await client.users.messages.get({ + userId: 'me', + id: messageId, + format: 'metadata', + metadataHeaders: ['To', 'Cc', 'Date'], + }); + const headers = res.data.payload?.headers ?? []; + const headerValue = (name: string) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? ''; + + const dateStr = headerValue('Date'); + const parsedDate = dateStr ? Date.parse(dateStr) : NaN; + const ts = Number.isFinite(parsedDate) ? parsedDate : Date.now(); + + const recipients = [ + ...parseAddressList(headerValue('To')), + ...parseAddressList(headerValue('Cc')), + ]; + for (const { name, email } of recipients) { + if (!email || email === selfEmail) continue; + let entry = map.get(email); + if (!entry) { + entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() }; + map.set(email, entry); + } + entry.count++; + if (ts > entry.lastSeenMs) entry.lastSeenMs = ts; + if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1); + } +} + +async function processInBatches(items: T[], size: number, fn: (item: T) => Promise): Promise { + for (let i = 0; i < items.length; i += size) { + const slice = items.slice(i, i + size); + await Promise.all(slice.map(async (item) => { + try { await fn(item); } + catch { /* skip failed individual messages */ } + })); + } +} + +async function fullSync(auth: OAuth2Client, selfEmail: string): Promise<{ map: Map; historyId: string | null }> { + const client = google.gmail({ version: 'v1', auth }); + + // Lock in the current historyId BEFORE we start listing, so any messages + // sent during the sync get caught by the next incremental run. + let startingHistoryId: string | null = null; + try { + const profile = await client.users.getProfile({ userId: 'me' }); + startingHistoryId = profile.data.historyId ?? null; + } catch { + startingHistoryId = null; + } + + const messageIds: string[] = []; + let pageToken: string | undefined; + do { + const res = await client.users.messages.list({ + userId: 'me', + labelIds: ['SENT'], + maxResults: 500, + pageToken, + }); + for (const m of res.data.messages ?? []) { + if (m.id) messageIds.push(m.id); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + const map = new Map(); + await processInBatches(messageIds, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map)); + promoteCanonicalNames(map); + return { map, historyId: startingHistoryId }; +} + +async function incrementalSync( + auth: OAuth2Client, + selfEmail: string, + startHistoryId: string, + map: Map, +): Promise<{ historyId: string | null; added: number } | null> { + const client = google.gmail({ version: 'v1', auth }); + const added: string[] = []; + let pageToken: string | undefined; + let latestHistoryId: string | null = null; + try { + do { + const res = await client.users.history.list({ + userId: 'me', + startHistoryId, + labelId: 'SENT', + historyTypes: ['messageAdded'], + maxResults: 500, + pageToken, + }); + for (const h of res.data.history ?? []) { + for (const m of h.messagesAdded ?? []) { + const labels = m.message?.labelIds ?? []; + const id = m.message?.id; + if (id && labels.includes('SENT')) added.push(id); + } + } + if (res.data.historyId) latestHistoryId = res.data.historyId; + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + } catch (err: unknown) { + // 404 means startHistoryId is too old — caller should fall back to full sync. + const status = (err as { code?: number; status?: number })?.code ?? (err as { code?: number; status?: number })?.status; + if (status === 404) return null; + throw err; + } + + // Dedupe in case the same message shows up in multiple history pages. + const unique = Array.from(new Set(added)); + await processInBatches(unique, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map)); + if (unique.length > 0) promoteCanonicalNames(map); + + // If history.list returned no entries we have no fresh historyId; keep + // using the watermark we started from so the next call retries the same window. + return { historyId: latestHistoryId ?? startHistoryId, added: unique.length }; +} + +async function performSync(): Promise { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return; + const selfRaw = await getUserEmail(auth).catch(() => null); + if (!selfRaw) return; + const selfEmail = selfRaw.trim().toLowerCase(); + + const stored = loadState(); + const sameAccount = stored?.selfEmail === selfEmail; + + if (stored && sameAccount && stored.historyId) { + const map = indexFromStored(stored); + const result = await incrementalSync(auth, selfEmail, stored.historyId, map); + if (result) { + cachedIndex = map; + await saveState(storedFromIndex(map, result.historyId, selfEmail, stored.lastFullSyncAt)); + lastRefreshAt = Date.now(); + return; + } + // history watermark too old → fall through to full sync. + } + + const { map, historyId } = await fullSync(auth, selfEmail); + cachedIndex = map; + await saveState(storedFromIndex(map, historyId, selfEmail, Date.now())); + lastRefreshAt = Date.now(); +} + +function ensureFresh(): void { + if (pendingSync) return; + if (Date.now() - lastRefreshAt < REFRESH_INTERVAL_MS) return; + pendingSync = performSync() + .catch((err) => { + console.error('[gmail_sent_contacts] sync failed:', err instanceof Error ? err.message : err); + }) + .finally(() => { + pendingSync = null; + }); +} + +// Public: kick off a sync on app startup. Subsequent calls within the refresh +// window are no-ops. +export function warmSentContacts(): void { + if (!cachedIndex) { + const stored = loadState(); + if (stored) cachedIndex = indexFromStored(stored); + } + ensureFresh(); +} + +export function invalidateSentContacts(): void { + cachedIndex = null; + lastRefreshAt = 0; +} + +function score(entry: IndexEntry, nowMs: number): number { + const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24)); + const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS); + return entry.count * (0.5 + 0.5 * recency); +} + +function matchTier(q: string, entry: IndexEntry): number { + if (!q) return 3; + const name = entry.name.toLowerCase(); + const email = entry.email; + if (name && name.startsWith(q)) return 0; + if (email.startsWith(q)) return 1; + if (name && name.includes(' ' + q)) return 1; + if (name && name.includes(q)) return 2; + if (email.includes(q)) return 3; + return -1; +} + +export interface SearchOpts { + limit?: number; + excludeEmails?: string[]; +} + +// Public: typeahead search over sent-recipient history. Returns instantly from +// the in-memory cache (or disk on first call) and triggers a background refresh. +export async function searchSentContacts(query: string, opts: SearchOpts = {}): Promise { + if (!cachedIndex) { + const stored = loadState(); + if (stored) cachedIndex = indexFromStored(stored); + } + // Kick off (or join) a background refresh; never block the user. + ensureFresh(); + + if (!cachedIndex) { + // First-ever launch: wait for the initial sync so we can return something + // useful instead of an empty list. + if (pendingSync) { + try { await pendingSync; } catch { /* return whatever we have */ } + } + if (!cachedIndex) return []; + } + + const q = query.trim().toLowerCase(); + const limit = Math.max(1, Math.min(50, opts.limit ?? 8)); + const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase())); + const nowMs = Date.now(); + + const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = []; + for (const entry of cachedIndex.values()) { + if (excluded.has(entry.email)) continue; + const tier = matchTier(q, entry); + if (tier < 0) continue; + matches.push({ entry, tier, s: score(entry, nowMs) }); + } + matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s)); + return matches.slice(0, limit).map(({ entry }) => ({ + name: entry.name, + email: entry.email, + count: entry.count, + lastSeenMs: entry.lastSeenMs, + })); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f3acffa1..e694be2f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -202,6 +202,21 @@ const ipcSchemas = { }), res: z.object({}), }, + 'gmail:searchContacts': { + req: z.object({ + query: z.string(), + limit: z.number().int().positive().optional(), + excludeEmails: z.array(z.string()).optional(), + }), + res: z.object({ + contacts: z.array(z.object({ + name: z.string(), + email: z.string(), + count: z.number(), + lastSeenMs: z.number(), + })), + }), + }, 'mcp:listTools': { req: z.object({ serverName: z.string(), From 9453e0d550246f15e9a719b796ecad3ce5d278ab Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 11 Jun 2026 01:45:10 +0530 Subject: [PATCH 05/10] feat: render background-task HTML output and open its links externally (#615) * feat: render background-task index.html output in a sandboxed iframe The task output pane now prefers bg-tasks//index.html when present and non-empty, rendering it full-bleed via HtmlFileViewer (app://workspace protocol) so CSS, layout, and scripts render faithfully. Falls back to the markdown index.md note when there is no HTML artifact. The viewer remounts on refreshKey so a re-run's updated HTML reloads. The Source/Rendered toggle works for both formats. The runner agent is instructed to choose index.md (default, notes) vs a self-contained index.html (visual/styled output) per run, written via the existing file-writeText tool. The Copilot background-task skill notes the HTML option so visual asks are steered toward it. * fix: open links from HTML report iframes in the system browser Links inside the sandboxed iframe that renders a background-task/workspace index.html did nothing on click, unlike the markdown viewer which opens links in the browser. Two causes: target="_blank" links were blocked by the sandbox before reaching the window-open handler, and plain links fire will-frame-navigate (subframe), which the app did not handle (will-navigate only covers the main frame). - Add allow-popups to the HtmlFileViewer iframe sandbox so target="_blank" reaches setWindowOpenHandler, which routes to shell.openExternal. - Handle will-frame-navigate in main, routing external subframe navigations to the system browser. Scoped to app://workspace frames so third-party note embeds (YouTube/Figma/Twitter) keep their internal navigation. --- apps/x/apps/main/src/main.ts | 32 +++++-- .../renderer/src/components/bg-tasks-view.tsx | 84 ++++++++++++------- .../src/components/html-file-viewer.tsx | 7 +- .../assistant/skills/background-task/skill.ts | 4 +- .../core/src/background-tasks/agent.ts | 9 +- 5 files changed, 98 insertions(+), 38 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f4415b5d..40e49e35 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -253,14 +253,34 @@ function createWindow() { return { action: "deny" }; }); - // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on("will-navigate", (event, url) => { + // Handle navigation to external URLs (e.g., clicking a link without target="_blank"). + // Returns true when the URL was external and routed to the system browser. + const routeExternalNavigation = (url: string): boolean => { const isInternal = url.startsWith("app://") || url.startsWith("http://localhost:5173"); - if (!isInternal) { - event.preventDefault(); - shell.openExternal(url); - } + if (isInternal) return false; + shell.openExternal(url); + return true; + }; + + win.webContents.on("will-navigate", (event, url) => { + if (routeExternalNavigation(url)) event.preventDefault(); + }); + + // Subframe navigations (e.g. links clicked inside the sandboxed iframe that + // renders a background-task / workspace `index.html`) fire `will-frame-navigate`, + // not `will-navigate`. Route their external links to the system browser too, + // so HTML reports behave like the markdown viewer. Main-frame navigations are + // already handled by `will-navigate` above — skip them here to avoid double-open. + // + // Scope this to our own HTML viewer frames (identified by their app://workspace + // document origin). Third-party note embeds (YouTube, Figma, Twitter via the + // embed/iframe blocks) load from their own origins — leave their internal + // navigation untouched so the embeds keep working. + win.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if (!event.frame?.url.startsWith("app://workspace/")) return; + if (routeExternalNavigation(event.url)) event.preventDefault(); }); // Attach the embedded browser pane manager to this window. diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index 4ba7479f..0641bc13 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' +import { HtmlFileViewer } from '@/components/html-file-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -502,15 +503,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re } // --------------------------------------------------------------------------- -// Output pane — index.md (main pane content) +// Output pane — index.html (preferred) or index.md (main pane content) // -// Renders the task's `index.md` like a note: max-width 720px centered, same -// typography (~16px, 1.5 line-height, generous padding) as the note editor's -// ProseMirror rule in `editor.css`. No chrome above the body — just the -// markdown, with a small floating Source ⇄ Rendered toggle in the top-right. +// A task's agent-owned artifact is either: +// - `index.html` — a self-contained, styled web page. Rendered full-bleed in +// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace` +// protocol) so CSS, layout, and scripts render faithfully. Preferred when +// present and non-empty. +// - `index.md` — a note. Rendered like the note editor: max-width 720px +// centered, same typography as `editor.css`, via `RichMarkdownViewer`. +// +// In both cases a small floating Source ⇄ Rendered toggle in the top-right +// swaps the rendered view for the raw file source. // --------------------------------------------------------------------------- function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) { + const [mode, setMode] = useState<'md' | 'html'>('md') const [body, setBody] = useState('') const [loading, setLoading] = useState(true) const [viewSource, setViewSource] = useState(false) @@ -519,21 +527,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st let cancelled = false setLoading(true) void (async () => { + // Prefer index.html when it exists and has content; otherwise fall + // back to index.md (the default seeded artifact). try { - const result = await window.ipc.invoke('workspace:readFile', { + const html = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.html`, + }) + if (html.data.trim()) { + if (!cancelled) { setMode('html'); setBody(html.data) } + return + } + } catch { + // No index.html — fall through to markdown. + } + try { + const md = await window.ipc.invoke('workspace:readFile', { path: `bg-tasks/${slug}/index.md`, }) - if (!cancelled) setBody(result.data) + if (!cancelled) { setMode('md'); setBody(md.data) } } catch { - if (!cancelled) setBody('') - } finally { - if (!cancelled) setLoading(false) + if (!cancelled) { setMode('md'); setBody('') } } - })() + })().finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [slug, refreshKey]) - const isEmpty = !body.trim() || body.trim() === `# ${taskName}` + const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`) + const showHtml = mode === 'html' && !viewSource return (
@@ -542,29 +562,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st type="button" onClick={() => setViewSource(v => !v)} className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground" - aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'} + aria-label={viewSource ? 'Show rendered output' : 'Show source'} > {viewSource ? 'Rendered' : 'Source'} )} -
-
- {loading ? ( -
- Loading… -
- ) : isEmpty ? ( -

- No output yet. Click Run now in the sidebar, or wait for a trigger to fire. -

- ) : viewSource ? ( -
{body}
- ) : ( - - )} + {showHtml ? ( + // Full-bleed: the iframe fills the pane and scrolls internally. + // Remount on refreshKey so a re-run's updated index.html reloads. + + ) : ( +
+
+ {loading ? ( +
+ Loading… +
+ ) : isEmpty ? ( +

+ No output yet. Click Run now in the sidebar, or wait for a trigger to fire. +

+ ) : viewSource ? ( +
{body}
+ ) : ( + + )} +
-
+ )}
) } diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 8343af28..79cccbc7 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {