diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 56a077ac..0f4b3155 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -53,7 +53,6 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) | | `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) | | `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) | -| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) | | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ef530ff0..095d6e24 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -98,7 +98,9 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, archiveCategoryThreads, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance, setThreadCategory } from '@x/core/dist/knowledge/sync_gmail.js'; +import { loadEmailInstructions, saveEmailInstructions } from '@x/core/dist/knowledge/email_instructions.js'; +import { getEmailLabels, syncCustomLabelsFromInstructions } from '@x/core/dist/knowledge/email_labels.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 { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; @@ -880,7 +882,7 @@ export function setupIpcHandlers() { return listImportantThreads({ cursor: args.cursor, limit: args.limit }); }, 'gmail:getEverythingElse': async (_event, args) => { - return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit, category: args.category }); }, 'gmail:triggerSync': async () => { triggerGmailSync(); @@ -914,6 +916,33 @@ export function setupIpcHandlers() { const result = setThreadImportance(args.threadId, args.importance); return { ok: result.success, previous: result.previous, error: result.error }; }, + 'gmail:setCategory': async (_event, args) => { + const result = setThreadCategory(args.threadId, args.category); + return { ok: result.success, error: result.error }; + }, + 'gmail:archiveCategory': async (_event, args) => { + return archiveCategoryThreads(args.category); + }, + 'gmail:getEmailInstructions': async () => { + return { instructions: loadEmailInstructions() }; + }, + 'gmail:setEmailInstructions': async (_event, args) => { + const saved = saveEmailInstructions(args.instructions); + if (!saved.ok) return saved; + // Extract any custom labels the instructions define so they become + // valid classifier outputs immediately. Extraction failure shouldn't + // fail the save — the instructions themselves are already persisted + // and still steer classification as free text. + try { + await syncCustomLabelsFromInstructions(args.instructions); + } catch (err) { + console.warn('[EmailLabels] custom label extraction failed:', err); + } + return saved; + }, + 'gmail:getEmailLabels': async () => { + return { labels: getEmailLabels().map(({ id, name, kind }) => ({ id, name, kind })) }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 1e1d3874..30cc9886 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,7 +23,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; -import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; @@ -522,9 +521,6 @@ app.whenReady().then(async () => { // start knowledge graph builder initGraphBuilder(); - // start email labeling service - initEmailLabeling(); - // start note tagging service initNoteTagging(); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 252a145f..6a7b644a 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -460,12 +460,24 @@ font-size: 13px; } +/* The bold lead (summary or subject) may shrink — with ellipsis — but only + after the snippet span (huge shrink factor below) has fully collapsed. + Without this, a long summary plus the never-shrinking category chip + overflows the clipped row and the chip is what gets cut off. */ .gmail-row-content strong { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; color: inherit; font-weight: 400; } +/* Snippet gives way first (chips carry .gmail-row-chip and keep shrink 0). */ +.gmail-row-content > span:not(.gmail-row-chip) { + flex-shrink: 1000; +} + .gmail-row-date { justify-self: end; color: var(--gm-text-faint); @@ -473,6 +485,114 @@ font-variant-numeric: tabular-nums; } +/* Category / waiting-age pill at the right edge of the content column. */ +.gmail-row-chip { + flex-shrink: 0; + margin-left: auto; + padding: 1px 8px; + border: 1px solid var(--gm-border); + border-radius: 999px; + color: var(--gm-text-faint); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +/* Two chips on one row (category + status): only the first takes the auto + margin; the second sits right next to it, so the pair clusters at the + right edge instead of spreading across the free space. */ +.gmail-row-chip + .gmail-row-chip { + margin-left: 6px; +} + +.gmail-row-chip-waiting { + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-muted); + font-variant-numeric: tabular-nums; +} + +.gmail-row-chip-ready { + border-color: transparent; + background: var(--gm-bg-row-selected); + color: var(--gm-accent); +} + +/* Shown in place of the "Needs you" section when it's empty. */ +.gmail-caughtup { + padding: 18px 24px; + color: var(--gm-text-faint); + font-size: 13px; +} + +/* Category filter pills under the "Everything else" header. */ +.gmail-category-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 24px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-category-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border: 1px solid var(--gm-border); + border-radius: 999px; + background: transparent; + color: var(--gm-text-muted); + font-family: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-category-pill:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-active { + background: var(--gm-bg-row-selected); + border-color: var(--gm-accent); + color: var(--gm-text-strong); +} + +.gmail-category-pill-count { + color: var(--gm-text-faint); + font-variant-numeric: tabular-nums; +} + +.gmail-category-pill-archive { + margin-left: auto; + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-archive:disabled { + opacity: 0.6; + cursor: default; +} + +/* The category chip in the thread-detail toolbar is a button (dropdown trigger). */ +.gmail-category-chip { + flex-shrink: 0; + background: transparent; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-category-chip:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + .gmail-detail { display: flex; min-width: 0; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 173e22ee..8301ac55 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, SlidersHorizontal, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -12,7 +12,14 @@ import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage @@ -90,6 +97,70 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { return thread.messages[thread.messages.length - 1] } +// The label set (chips, filter pills, correction dropdown) comes from the +// backend label registry: built-ins (display names follow Superhuman's Auto +// Label vocabulary — Marketing / Pitch / News / Calendar) plus any labels the +// user defined in their agent instructions. This static copy of the built-ins +// is the fallback used until the registry loads, and for ids the registry no +// longer knows (a custom label the user later removed). +type EmailCategory = string + +interface EmailLabelInfo { + id: string + name: string + kind: 'builtin' | 'custom' +} + +const BUILTIN_LABELS: EmailLabelInfo[] = [ + { id: 'correspondence', name: 'Direct', kind: 'builtin' }, + { id: 'meeting', name: 'Calendar', kind: 'builtin' }, + { id: 'notification', name: 'Notification', kind: 'builtin' }, + { id: 'newsletter', name: 'News', kind: 'builtin' }, + { id: 'promotion', name: 'Marketing', kind: 'builtin' }, + { id: 'cold_outreach', name: 'Pitch', kind: 'builtin' }, + { id: 'receipt', name: 'Receipt', kind: 'builtin' }, +] + +// Pill order in the "Everything else" filter row: noise first (that's what +// gets bulk-archived), then the rest of the built-ins; custom labels are +// appended in registry order at render time. 'unclassified' (threads the +// classifier hasn't reached yet) is always last and unarchivable. +const BUILTIN_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence'] + +// Fallback for ids the registry doesn't know: "portfolio_updates" → "Portfolio updates". +function prettifyLabelId(id: string): string { + const words = id.replace(/_/g, ' ').trim() + return words ? words[0].toUpperCase() + words.slice(1) : id +} + +function labelNameFor(labels: EmailLabelInfo[], category: string): string { + if (category === 'unclassified') return 'Uncategorized' + return labels.find((l) => l.id === category)?.name ?? prettifyLabelId(category) +} + +// The user sent the latest message and someone else is in the conversation — +// the ball is in their court. Derived from the messages on every render (never +// stored) so it can't go stale the way an arrival-time label would. +function isAwaitingThem(thread: GmailThread, selfEmail: string | null | undefined): boolean { + const self = (selfEmail || '').trim().toLowerCase() + if (!self) return false + const latest = latestMessage(thread) + if (!(latest?.from || '').toLowerCase().includes(self)) return false + return thread.messages.some((m) => m.from && !m.from.toLowerCase().includes(self)) +} + +function daysSince(value?: string): number | null { + if (!value) return null + const ms = Date.parse(value) + if (!Number.isFinite(ms)) return null + return Math.max(0, Math.floor((Date.now() - ms) / 86_400_000)) +} + +function waitingChip(thread: GmailThread): string { + const days = daysSince(latestMessage(thread)?.date || thread.date) + return days && days > 0 ? `Waiting ${days}d` : 'Waiting' +} + // Split a raw header recipient string (e.g. `"Jo Bloggs" , b@y.com`) into // individual address tokens, respecting commas inside quotes/angle brackets. function splitAddresses(raw?: string): string[] { @@ -1244,6 +1315,15 @@ const ComposeBox = memo(function ComposeBox({ setGenerating(true) try { + // The user's standing email-agent instructions steer this writer too — + // otherwise "keep drafts short, sign off with X" would apply to the + // auto-drafted replies but silently not to the Write-with-AI bar. + try { + const { instructions } = await window.ipc.invoke('gmail:getEmailInstructions', {}) + if (instructions.trim()) { + system = `${system}\n\nThe user's standing email preferences — follow any that concern how emails should be written (tone, length, sign-off, what not to write):\n${instructions.trim()}` + } + } catch { /* draft without them */ } // Draft through Copilot: no model override, so the backend resolves the // same default model/provider the Copilot chat uses (models.json). const res = await window.ipc.invoke('llm:generate', { prompt, system }) @@ -1910,6 +1990,8 @@ function ThreadDetail({ hidden, keysDisabled, onComposingChange, + onSetCategory, + labels = BUILTIN_LABELS, }: { thread: GmailThread onClose: () => void @@ -1918,6 +2000,10 @@ function ThreadDetail({ keysDisabled?: boolean /** Reports whether the inline composer is open, so list shortcuts pause. */ onComposingChange?: (composing: boolean) => void + /** Present for inbox threads only — search results aren't in the cache the correction writes to. */ + onSetCategory?: (threadId: string, category: EmailCategory) => Promise + /** The label registry (built-ins + user-defined) for the chip and correction dropdown. */ + labels?: EmailLabelInfo[] }) { const [composeMode, setComposeMode] = useState(null) const [selfEmail, setSelfEmail] = useState('') @@ -1999,6 +2085,30 @@ function ThreadDetail({