From 8436e0e245df76e16c16422b283f44692fea1916 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:40:17 +0530 Subject: [PATCH] allow labels and drafts to be steered by the user in the app --- apps/x/apps/main/src/ipc.ts | 22 ++ apps/x/apps/renderer/src/App.css | 14 +- .../renderer/src/components/email-view.tsx | 214 ++++++++++++++---- .../core/src/knowledge/classify_thread.ts | 92 +++++--- .../core/src/knowledge/email_instructions.ts | 69 ++++++ .../core/src/knowledge/email_labels.ts | 183 +++++++++++++++ apps/x/packages/shared/src/ipc.ts | 27 ++- 7 files changed, 543 insertions(+), 78 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/email_instructions.ts create mode 100644 apps/x/packages/core/src/knowledge/email_labels.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index f43fa3e7..095d6e24 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -99,6 +99,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, 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'; @@ -921,6 +923,26 @@ export function setupIpcHandlers() { '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/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 94ac02df..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); diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 93e3734d..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,6 +12,7 @@ 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, @@ -96,34 +97,45 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { return thread.messages[thread.messages.length - 1] } -// Chip labels for the "Everything else" section — the reason a thread was -// filed there. Plain correspondence gets no chip: chips explain the filing, -// and correspondence is the default kind of mail. -const CATEGORY_CHIP_LABELS: Record = { - meeting: 'Meeting', - notification: 'Notification', - newsletter: 'Newsletter', - promotion: 'Promotion', - cold_outreach: 'Cold outreach', - receipt: 'Receipt', +// 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' } -type EmailCategory = 'correspondence' | 'meeting' | 'notification' | 'newsletter' | 'promotion' | 'cold_outreach' | 'receipt' +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' }, +] -// Full label set for the correction dropdown (correspondence included there — -// "this newsletter is actually a real person" is the most valuable fix). -const ALL_CATEGORY_LABELS: Record = { - correspondence: 'Correspondence', - ...CATEGORY_CHIP_LABELS, -} as Record +// 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'] -// Pill order in the "Everything else" filter row. 'unclassified' (threads the -// classifier hasn't reached yet) is deliberately last and unarchivable. -const CATEGORY_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence', 'unclassified'] +// 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 categoryPillLabel(category: string): string { +function labelNameFor(labels: EmailLabelInfo[], category: string): string { if (category === 'unclassified') return 'Uncategorized' - return ALL_CATEGORY_LABELS[category as EmailCategory] ?? category + return labels.find((l) => l.id === category)?.name ?? prettifyLabelId(category) } // The user sent the latest message and someone else is in the conversation — @@ -1303,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 }) @@ -1970,6 +1991,7 @@ function ThreadDetail({ keysDisabled, onComposingChange, onSetCategory, + labels = BUILTIN_LABELS, }: { thread: GmailThread onClose: () => void @@ -1980,6 +2002,8 @@ function ThreadDetail({ 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('') @@ -2069,17 +2093,17 @@ function ThreadDetail({ className="gmail-row-chip gmail-category-chip" title="Correct the category — the classifier learns from this" > - {thread.category ? categoryPillLabel(thread.category) : 'Categorize'} + {thread.category ? labelNameFor(labels, thread.category) : 'Categorize'} - {(Object.keys(ALL_CATEGORY_LABELS) as EmailCategory[]).map((cat) => ( + {labels.map((l) => ( { void onSetCategory(thread.threadId, cat) }} - className={cn(cat === thread.category && 'font-semibold')} + key={l.id} + onSelect={() => { void onSetCategory(thread.threadId, l.id) }} + className={cn(l.id === thread.category && 'font-semibold')} > - {ALL_CATEGORY_LABELS[cat]} + {l.name} ))} @@ -2179,6 +2203,7 @@ const ThreadRow = memo(function ThreadRow({ chip, chipWaiting, onSetCategory, + labels, onToggle, onMarkRead, onArchive, @@ -2201,6 +2226,8 @@ const ThreadRow = memo(function ThreadRow({ chip?: string | null chipWaiting?: boolean onSetCategory?: (threadId: string, category: EmailCategory) => Promise + /** Label registry — stable reference from EmailView state (this row is memoized). */ + labels: EmailLabelInfo[] onToggle: (thread: GmailThread) => void onMarkRead: (threadId: string, read?: boolean) => Promise onArchive: (threadId: string) => Promise @@ -2213,10 +2240,14 @@ const ThreadRow = memo(function ThreadRow({ }) { const latest = latestMessage(thread) const isUnread = thread.unread === true - // Category chip on every row so the list itself answers "worth opening?". - // Plain correspondence stays chipless — labeling normal human mail - // "Correspondence" adds noise, not information. - const categoryChip = (thread.category && CATEGORY_CHIP_LABELS[thread.category]) || null + // Category chip on classified rows so the list itself answers "worth + // opening?". Plain correspondence gets none — with granular labels + // (Investor, Customer, …) available, "Direct" is the default case and + // absence reads cleaner than a chip stating it. It stays available in the + // correction dropdown and the Everything-else filter pills. + const categoryChip = thread.category && thread.category !== 'correspondence' + ? labelNameFor(labels, thread.category) + : null const stop = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation() } @@ -2294,12 +2325,87 @@ const ThreadRow = memo(function ThreadRow({ keysDisabled={keysDisabled} onComposingChange={onComposingChange} onSetCategory={onSetCategory} + labels={labels} /> )} ) }) +// Free-text standing instructions for the email agent. Saved to +// config/email_instructions.md and injected into every classification / +// draft call as the highest-priority section of the system prompt. +function EmailInstructionsDialog({ open, onOpenChange, onSaved }: { open: boolean; onOpenChange: (open: boolean) => void; onSaved?: () => void }) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!open) return + let cancelled = false + setLoading(true) + window.ipc.invoke('gmail:getEmailInstructions', {}) + .then((res) => { if (!cancelled) setText(res.instructions) }) + .catch(() => {}) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [open]) + + const save = async () => { + setSaving(true) + try { + const result = await window.ipc.invoke('gmail:setEmailInstructions', { instructions: text }) + if (result.ok) { + toast('Instructions saved — they apply to every email from now on.', 'success') + onOpenChange(false) + onSaved?.() + } else { + toast(`Could not save: ${result.error ?? 'unknown error'}`, 'error') + } + } catch (err) { + toast(`Could not save: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + setSaving(false) + } + } + + return ( + + + Email agent instructions +

+ Standing instructions for how your email is labeled, prioritized, and how draft replies are written. + The agent follows these over its defaults, on every new email. You can also define your own labels + here — e.g. “Create a label Clients for emails from active client accounts.” +

+