email labels

This commit is contained in:
Arjun 2026-03-02 11:48:03 +05:30 committed by arkml
parent e8a666499a
commit 5131fb7f9e
24 changed files with 2276 additions and 3197 deletions

View file

@ -17,6 +17,8 @@ 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 initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
@ -170,6 +172,12 @@ app.whenReady().then(async () => {
// start knowledge graph builder
initGraphBuilder();
// start email labeling service
initEmailLabeling();
// start note tagging service
initNoteTagging();
// start background agent runner (scheduled agents)
initAgentRunner();

View file

@ -46,6 +46,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter'
import { OnboardingModal } from '@/components/onboarding-modal'
import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
@ -509,6 +510,10 @@ function App() {
const initialContentRef = useRef<string>('')
const renameInProgressRef = useRef(false)
// Frontmatter state: store raw frontmatter per file path, tags for active file
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
const [activeFileTags, setActiveFileTags] = useState<string[]>([])
// Version history state
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
@ -892,12 +897,15 @@ function App() {
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
if (selectedPathRef.current !== pathToReload) return
setFileContent(result.data)
setEditorContent(result.data)
setEditorCacheForPath(pathToReload, result.data)
editorContentRef.current = result.data
const { raw: fm, body } = splitFrontmatter(result.data)
frontmatterByPathRef.current.set(pathToReload, fm)
setEditorContent(body)
setEditorCacheForPath(pathToReload, body)
editorContentRef.current = body
editorPathRef.current = pathToReload
initialContentByPathRef.current.set(pathToReload, result.data)
initialContentRef.current = result.data
initialContentByPathRef.current.set(pathToReload, body)
initialContentRef.current = body
setActiveFileTags(extractTags(fm))
}
}
})
@ -926,6 +934,7 @@ function App() {
editorContentRef.current = cachedContent
editorPathRef.current = selectedPath
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null))
return
}
}
@ -940,6 +949,8 @@ function App() {
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
setFileContent(result.data)
const { raw: fm, body } = splitFrontmatter(result.data)
frontmatterByPathRef.current.set(pathToLoad, fm)
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
const isSameEditorFile = editorPathRef.current === pathToLoad
const knownBaseline = initialContentByPathRef.current.get(pathToLoad)
@ -949,15 +960,16 @@ function App() {
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
if (!shouldPreserveActiveDraft) {
setEditorContent(result.data)
setEditorContent(body)
if (pathToLoad.endsWith('.md')) {
setEditorCacheForPath(pathToLoad, result.data)
setEditorCacheForPath(pathToLoad, body)
}
editorContentRef.current = result.data
editorContentRef.current = body
editorPathRef.current = pathToLoad
initialContentByPathRef.current.set(pathToLoad, result.data)
initialContentRef.current = result.data
initialContentByPathRef.current.set(pathToLoad, body)
initialContentRef.current = body
setLastSaved(null)
setActiveFileTags(extractTags(fm))
} else {
// Still update the editor's path so subsequent autosaves write to the correct file.
editorPathRef.current = pathToLoad
@ -1006,7 +1018,7 @@ function App() {
const wasActiveAtStart = selectedPathRef.current === pathAtStart
if (wasActiveAtStart) setIsSaving(true)
let pathToSave = pathAtStart
let contentToSave = debouncedContent
let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent)
let renamedFrom: string | null = null
let renamedTo: string | null = null
try {
@ -1036,16 +1048,21 @@ function App() {
renameInProgressRef.current = true
await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
pathToSave = targetPath
contentToSave = rewriteWikiLinksForRenamedFileInMarkdown(
const rewrittenBody = rewriteWikiLinksForRenamedFileInMarkdown(
debouncedContent,
pathAtStart,
targetPath
)
contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, rewrittenBody)
renamedFrom = pathAtStart
renamedTo = targetPath
editorPathRef.current = targetPath
untitledRenameReadyPathsRef.current.delete(pathAtStart)
setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
// Migrate frontmatter entry
const fmEntry = frontmatterByPathRef.current.get(pathAtStart)
frontmatterByPathRef.current.delete(pathAtStart)
frontmatterByPathRef.current.set(targetPath, fmEntry ?? null)
initialContentByPathRef.current.delete(pathAtStart)
const cachedContent = editorContentByPathRef.current.get(pathAtStart)
if (cachedContent !== undefined) {
@ -1070,8 +1087,9 @@ function App() {
})
}
if (selectedPathRef.current === pathAtStart) {
editorContentRef.current = contentToSave
setEditorContent(contentToSave)
const bodyForEditor = splitFrontmatter(contentToSave).body
editorContentRef.current = bodyForEditor
setEditorContent(bodyForEditor)
}
}
}
@ -1083,7 +1101,8 @@ function App() {
opts: { encoding: 'utf8' }
})
markRecentLocalMarkdownWrite(pathToSave)
initialContentByPathRef.current.set(pathToSave, contentToSave)
// Store body-only baseline (matches what debouncedContent compares against)
initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body)
// If we renamed the active file, update state/history AFTER the write completes so the editor
// doesn't reload stale on-disk content mid-typing (which can drop the latest character).
@ -1104,7 +1123,7 @@ function App() {
// Only update "current file" UI state if we're still on this file
if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
initialContentRef.current = contentToSave
initialContentRef.current = splitFrontmatter(contentToSave).body
setLastSaved(new Date())
}
} catch (err) {
@ -2162,6 +2181,7 @@ function App() {
removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path)
frontmatterByPathRef.current.delete(closingTab.path)
if (editorPathRef.current === closingTab.path) {
editorPathRef.current = null
}
@ -2768,6 +2788,12 @@ function App() {
if (editorPathRef.current === oldPath) {
editorPathRef.current = newPath
}
// Migrate frontmatter entry
const fmEntry = frontmatterByPathRef.current.get(oldPath)
if (fmEntry !== undefined) {
frontmatterByPathRef.current.delete(oldPath)
frontmatterByPathRef.current.set(newPath, fmEntry)
}
const baseline = initialContentByPathRef.current.get(oldPath)
if (baseline !== undefined) {
initialContentByPathRef.current.delete(oldPath)
@ -2805,6 +2831,7 @@ function App() {
removeEditorCacheForPath(path)
initialContentByPathRef.current.delete(path)
untitledRenameReadyPathsRef.current.delete(path)
frontmatterByPathRef.current.delete(path)
}
// Close any file tab showing the deleted file
const tabForFile = fileTabs.find(t => t.path === path)
@ -3391,6 +3418,7 @@ function App() {
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
tags={isActive ? activeFileTags : undefined}
onHistoryHandlersChange={(handlers) => {
if (handlers) {
fileHistoryHandlersRef.current.set(tab.id, handlers)

View file

@ -0,0 +1,310 @@
import * as React from 'react'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import { ArrowDown, ArrowUp, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { splitFrontmatter, extractTags } from '@/lib/frontmatter'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
type NoteEntry = {
path: string
name: string
folder: string
tags: string[]
mtimeMs: number
}
type SortField = 'name' | 'folder' | 'mtimeMs'
type SortDir = 'asc' | 'desc'
type BasesViewProps = {
tree: TreeNode[]
onSelectNote: (path: string) => void
}
function collectFilePaths(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
return nodes.flatMap((n) =>
n.kind === 'file' && n.name.endsWith('.md')
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
: n.children
? collectFilePaths(n.children)
: [],
)
}
/** Build a stable fingerprint from the tree's file paths + mtimes so we only reload when files actually change. */
function treeFingerprint(nodes: TreeNode[]): string {
const files = collectFilePaths(nodes)
return files.map((f) => `${f.path}:${f.mtimeMs}`).join('\n')
}
function getFolder(path: string): string {
const parts = path.split('/')
if (parts.length >= 3) return parts[1]
return ''
}
function formatDate(ms: number): string {
if (!ms) return ''
const d = new Date(ms)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function BasesView({ tree, onSelectNote }: BasesViewProps) {
const [notes, setNotes] = useState<NoteEntry[]>([])
const [initialLoading, setInitialLoading] = useState(true)
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
const [sortField, setSortField] = useState<SortField>('mtimeMs')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const lastFingerprintRef = useRef<string>('')
// Stable fingerprint — only changes when actual file paths/mtimes differ
const fingerprint = useMemo(() => treeFingerprint(tree), [tree])
// Load notes data when fingerprint changes
useEffect(() => {
if (fingerprint === lastFingerprintRef.current) return
lastFingerprintRef.current = fingerprint
let cancelled = false
const files = collectFilePaths(tree)
async function loadNotes() {
const entries: NoteEntry[] = []
for (const file of files) {
try {
const result = await window.ipc.invoke('workspace:readFile', {
path: file.path,
encoding: 'utf8',
})
const { raw } = splitFrontmatter(result.data)
const tags = extractTags(raw)
entries.push({
path: file.path,
name: file.name,
folder: getFolder(file.path),
tags,
mtimeMs: file.mtimeMs,
})
} catch {
entries.push({
path: file.path,
name: file.name,
folder: getFolder(file.path),
tags: [],
mtimeMs: file.mtimeMs,
})
}
}
if (!cancelled) {
setNotes(entries)
setInitialLoading(false)
}
}
loadNotes()
return () => { cancelled = true }
}, [fingerprint, tree])
// Collect all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>()
for (const note of notes) {
for (const tag of note.tags) {
tagSet.add(tag)
}
}
return [...tagSet].sort((a, b) => a.localeCompare(b))
}, [notes])
// Filter and sort
const filteredNotes = useMemo(() => {
let result = notes
if (selectedTags.size > 0) {
const tagsArray = [...selectedTags]
result = result.filter((note) =>
tagsArray.every((tag) => note.tags.includes(tag)),
)
}
result = [...result].sort((a, b) => {
let cmp = 0
if (sortField === 'name') {
cmp = a.name.localeCompare(b.name)
} else if (sortField === 'folder') {
cmp = a.folder.localeCompare(b.folder)
} else {
cmp = a.mtimeMs - b.mtimeMs
}
return sortDir === 'asc' ? cmp : -cmp
})
return result
}, [notes, selectedTags, sortField, sortDir])
const toggleTag = useCallback((tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev)
if (next.has(tag)) {
next.delete(tag)
} else {
next.add(tag)
}
return next
})
}, [])
const clearFilters = useCallback(() => {
setSelectedTags(new Set())
}, [])
const handleSort = useCallback((field: SortField) => {
setSortField((prev) => {
if (prev === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
return prev
}
setSortDir(field === 'mtimeMs' ? 'desc' : 'asc')
return field
})
}, [])
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return null
return sortDir === 'asc' ? (
<ArrowUp className="size-3 inline ml-1" />
) : (
<ArrowDown className="size-3 inline ml-1" />
)
}
if (initialLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="space-y-3 w-full max-w-2xl px-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 rounded bg-muted/50 animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Filter bar */}
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">
Showing {filteredNotes.length} of {notes.length} notes
</span>
{selectedTags.size > 0 && (
<button
onClick={clearFilters}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<X className="size-3" />
Clear filters
</button>
)}
<div className="flex items-center gap-1 flex-wrap">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
selectedTags.has(tag)
? 'bg-primary text-primary-foreground border-transparent'
: 'bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground',
)}
>
{tag}
</button>
))}
</div>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-background border-b border-border">
<tr>
<th
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
onClick={() => handleSort('name')}
>
Name
<SortIcon field="name" />
</th>
<th
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
onClick={() => handleSort('folder')}
>
Folder
<SortIcon field="folder" />
</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground select-none">
Tags
</th>
<th
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
onClick={() => handleSort('mtimeMs')}
>
Last Modified
<SortIcon field="mtimeMs" />
</th>
</tr>
</thead>
<tbody>
{filteredNotes.map((note) => (
<tr
key={note.path}
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectNote(note.path)}
>
<td className="px-4 py-2 font-medium">{note.name}</td>
<td className="px-4 py-2 text-muted-foreground">{note.folder}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-1 flex-wrap">
{note.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-primary hover:text-primary-foreground"
onClick={(e) => {
e.stopPropagation()
toggleTag(tag)
}}
>
{tag}
</Badge>
))}
</div>
</td>
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
{formatDate(note.mtimeMs)}
</td>
</tr>
))}
{filteredNotes.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
No notes found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View file

@ -176,6 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
return result
}
import { EditorToolbar } from './editor-toolbar'
import { TagPills } from './tag-pills'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
@ -200,6 +201,7 @@ interface MarkdownEditorProps {
editorSessionKey?: number
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
editable?: boolean
tags?: string[]
}
type WikiLinkMatch = {
@ -288,6 +290,7 @@ export function MarkdownEditor({
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
tags,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -721,6 +724,7 @@ export function MarkdownEditor({
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
/>
{tags && <TagPills tags={tags} />}
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, ChevronRight, Plus, X } from "lucide-react"
import {
Dialog,
@ -22,7 +22,7 @@ import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
type ConfigTab = "models" | "mcp" | "security" | "appearance"
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
interface TabConfig {
id: ConfigTab
@ -60,6 +60,13 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "note-tagging",
label: "Note Tagging",
icon: Tags,
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
]
interface SettingsDialogProps {
@ -685,6 +692,311 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Note Tagging Settings ---
interface TagDef {
tag: string
type: string
applicability: "email" | "notes" | "both"
description: string
example?: string
}
const TAG_TYPE_ORDER = [
"relationship", "relationship-sub", "topic", "email-type",
"filter", "action", "status", "source",
]
const TAG_TYPE_LABELS: Record<string, string> = {
"relationship": "Relationship",
"relationship-sub": "Relationship Sub-Tags",
"topic": "Topic",
"email-type": "Email Type",
"filter": "Filter",
"action": "Action",
"status": "Status",
"source": "Source",
}
const DEFAULT_TAGS: TagDef[] = [
{ tag: "investor", type: "relationship", applicability: "both", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
{ tag: "customer", type: "relationship", applicability: "both", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
{ tag: "prospect", type: "relationship", applicability: "both", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
{ tag: "partner", type: "relationship", applicability: "both", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
{ tag: "vendor", type: "relationship", applicability: "both", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
{ tag: "product", type: "relationship", applicability: "both", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
{ tag: "candidate", type: "relationship", applicability: "both", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
{ tag: "team", type: "relationship", applicability: "both", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
{ tag: "advisor", type: "relationship", applicability: "both", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
{ tag: "personal", type: "relationship", applicability: "both", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
{ tag: "press", type: "relationship", applicability: "both", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
{ tag: "community", type: "relationship", applicability: "both", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
{ tag: "government", type: "relationship", applicability: "both", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
{ tag: "primary", type: "relationship-sub", applicability: "notes", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
{ tag: "secondary", type: "relationship-sub", applicability: "notes", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
{ tag: "cc", type: "relationship-sub", applicability: "notes", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
{ tag: "former", type: "relationship-sub", applicability: "notes", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
{ tag: "champion", type: "relationship-sub", applicability: "notes", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
{ tag: "blocker", type: "relationship-sub", applicability: "notes", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
{ tag: "sales", type: "topic", applicability: "both", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
{ tag: "support", type: "topic", applicability: "both", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
{ tag: "legal", type: "topic", applicability: "both", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
{ tag: "finance", type: "topic", applicability: "both", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
{ tag: "hiring", type: "topic", applicability: "both", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
{ tag: "fundraising", type: "topic", applicability: "both", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
{ tag: "travel", type: "topic", applicability: "both", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
{ tag: "event", type: "topic", applicability: "both", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
{ tag: "shopping", type: "topic", applicability: "both", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
{ tag: "health", type: "topic", applicability: "both", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
{ tag: "learning", type: "topic", applicability: "both", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
{ tag: "research", type: "topic", applicability: "both", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
{ tag: "intro", type: "email-type", applicability: "both", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
{ tag: "followup", type: "email-type", applicability: "both", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
{ tag: "scheduling", type: "email-type", applicability: "email", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
{ tag: "cold-outreach", type: "email-type", applicability: "email", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
{ tag: "newsletter", type: "email-type", applicability: "email", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
{ tag: "notification", type: "email-type", applicability: "email", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
{ tag: "spam", type: "filter", applicability: "email", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
{ tag: "promotion", type: "filter", applicability: "email", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
{ tag: "social", type: "filter", applicability: "email", description: "Social media notifications", example: "John Smith commented on your post." },
{ tag: "forums", type: "filter", applicability: "email", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
{ tag: "action-required", type: "action", applicability: "both", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
{ tag: "fyi", type: "action", applicability: "email", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
{ tag: "urgent", type: "action", applicability: "both", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
{ tag: "waiting", type: "action", applicability: "both", description: "Waiting on a response from them" },
{ tag: "unread", type: "status", applicability: "email", description: "Not yet processed" },
{ tag: "to-reply", type: "status", applicability: "email", description: "Need to respond" },
{ tag: "done", type: "status", applicability: "email", description: "Handled, can be archived" },
{ tag: "active", type: "status", applicability: "notes", description: "Currently relevant, recent activity" },
{ tag: "archived", type: "status", applicability: "notes", description: "No longer active, kept for reference" },
{ tag: "stale", type: "status", applicability: "notes", description: "No activity in 60+ days, needs attention or archive" },
{ tag: "email", type: "source", applicability: "notes", description: "Created or updated from email" },
{ tag: "meeting", type: "source", applicability: "notes", description: "Created or updated from meeting transcript" },
{ tag: "browser", type: "source", applicability: "notes", description: "Content captured from web browsing" },
{ tag: "web-search", type: "source", applicability: "notes", description: "Information from web search" },
{ tag: "manual", type: "source", applicability: "notes", description: "Manually entered by user" },
{ tag: "import", type: "source", applicability: "notes", description: "Imported from another system" },
]
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [tags, setTags] = useState<TagDef[]>([])
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
const parsed = JSON.parse(result.data)
setTags(parsed)
setOriginalTags(parsed)
} catch {
setTags([...DEFAULT_TAGS])
setOriginalTags([...DEFAULT_TAGS])
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const groups = useMemo(() => {
const map = new Map<string, TagDef[]>()
for (const tag of tags) {
const list = map.get(tag.type) ?? []
list.push(tag)
map.set(tag.type, list)
}
return TAG_TYPE_ORDER.map(type => ({
type,
label: TAG_TYPE_LABELS[type],
tags: map.get(type) ?? [],
}))
}, [tags])
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
let count = 0
for (let i = 0; i < tags.length; i++) {
if (tags[i].type === type) {
if (count === localIndex) return i
count++
}
}
return -1
}, [tags])
const updateTag = useCallback((index: number, field: keyof TagDef, value: string) => {
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
}, [])
const removeTag = useCallback((index: number) => {
setTags(prev => prev.filter((_, i) => i !== index))
}, [])
const addTag = useCallback((type: string) => {
const newTag: TagDef = { tag: "", type, applicability: "both", description: "" }
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
if (lastIndex === -1) {
setTags(prev => [...prev, newTag])
} else {
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
}
}, [tags])
const handleSave = useCallback(async () => {
setSaving(true)
try {
await window.ipc.invoke("workspace:writeFile", {
path: "config/tags.json",
data: JSON.stringify(tags, null, 2),
})
setOriginalTags([...tags])
toast.success("Tag configuration saved")
} catch {
toast.error("Failed to save tag configuration")
} finally {
setSaving(false)
}
}, [tags])
const handleReset = useCallback(() => {
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
setTags([...DEFAULT_TAGS])
}, [])
const toggleGroup = useCallback((type: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(type)) next.delete(type)
else next.add(type)
return next
})
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{groups.map(group => (
<div key={group.type}>
<div className="flex items-center justify-between mb-1.5">
<button
onClick={() => toggleGroup(group.type)}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("size-3.5 transition-transform", !collapsedGroups.has(group.type) && "rotate-90")} />
{group.label}
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
</button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => addTag(group.type)}
>
<Plus className="size-3 mr-1" />
Add
</Button>
</div>
{!collapsedGroups.has(group.type) && group.tags.length > 0 && (
<div className="border rounded-md overflow-hidden">
<div className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
<div>Tag</div>
<div>Description</div>
<div>Example</div>
<div>Applies to</div>
<div />
</div>
{group.tags.map((tag, localIdx) => {
const globalIdx = getGlobalIndex(group.type, localIdx)
return (
<div key={globalIdx} className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 border-t px-2 py-0.5 items-center">
<Input
value={tag.tag}
onChange={e => updateTag(globalIdx, "tag", e.target.value)}
className="h-7 text-xs"
placeholder="tag-name"
title={tag.tag}
/>
<Input
value={tag.description}
onChange={e => updateTag(globalIdx, "description", e.target.value)}
className="h-7 text-xs"
placeholder="Description"
title={tag.description}
/>
<Input
value={tag.example || ""}
onChange={e => updateTag(globalIdx, "example", e.target.value)}
className="h-7 text-xs"
placeholder="Example"
title={tag.example || ""}
/>
<Select
value={tag.applicability}
onValueChange={v => updateTag(globalIdx, "applicability", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="notes">Notes</SelectItem>
</SelectContent>
</Select>
<button
onClick={() => removeTag(globalIdx)}
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
>
<X className="size-3.5" />
</button>
</div>
)
})}
</div>
)}
{!collapsedGroups.has(group.type) && group.tags.length === 0 && (
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
)}
</div>
))}
</div>
<div className="pt-3 border-t mt-3 flex items-center justify-between">
<div>
{hasChanges && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
Reset to defaults
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
@ -708,7 +1020,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -814,9 +1126,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : loading ? (

View file

@ -0,0 +1,17 @@
interface TagPillsProps {
tags: string[]
}
export function TagPills({ tags }: TagPillsProps) {
if (tags.length === 0) return null
return (
<div className="tag-pills-row">
{tags.map((tag, i) => (
<span key={`${tag}-${i}`} className="tag-pill">
{tag}
</span>
))}
</div>
)
}

View file

@ -0,0 +1,164 @@
/**
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
* in knowledge notes and email files.
*/
/** Split content into raw frontmatter block and body text. */
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
return { raw: null, body: content }
}
const endIndex = content.indexOf('\n---', 3)
if (endIndex === -1) {
return { raw: null, body: content }
}
// raw includes both delimiters and the trailing newline after closing ---
const closingEnd = endIndex + 4 // '\n---' is 4 chars
const raw = content.slice(0, closingEnd)
// body starts after the closing --- and its trailing newline
let body = content.slice(closingEnd)
if (body.startsWith('\n')) {
body = body.slice(1)
}
return { raw, body }
}
/** Re-prepend raw frontmatter before body when saving. */
export function joinFrontmatter(raw: string | null, body: string): string {
if (!raw) return body
return raw + '\n' + body
}
/** Tag category keys used in the categorized frontmatter format. */
const TAG_CATEGORY_KEYS = new Set([
'relationship',
'relationship_sub',
'topic',
'email_type',
'action',
'status',
'source',
])
/** Keys that are metadata, not tags — skip when collecting tags. */
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
/**
* Extract tags from raw frontmatter YAML.
*
* Handles three formats:
* - Legacy flat list: `tags:` followed by ` - value` items
* - Categorized format: top-level keys like `relationship: customer` or
* `topic:` followed by ` - value` list items
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
* where values can be single strings or ` - value` arrays
*
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
*/
export function extractTags(raw: string | null): string[] {
if (!raw) return []
const lines = raw.split('\n')
const tags: string[] = []
let inTags = false
let inLabels = false
let inLabelSubKey = false
let inCategoryList = false
for (const line of lines) {
// Top-level key detection — resets all nested state
if (/^\w/.test(line) || line === '---') {
inTags = false
inLabels = false
inLabelSubKey = false
inCategoryList = false
}
// Legacy note format: tags:
if (/^tags:\s*$/.test(line)) {
inTags = true
inLabels = false
inCategoryList = false
continue
}
// Email format: labels:
if (/^labels:\s*$/.test(line)) {
inLabels = true
inTags = false
inCategoryList = false
continue
}
// Categorized format: top-level tag category key
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
if (topKeyMatch) {
const key = topKeyMatch[1]
const inlineValue = topKeyMatch[2].trim()
if (TAG_CATEGORY_KEYS.has(key)) {
if (inlineValue) {
// Single value: `relationship: customer`
tags.push(inlineValue)
inCategoryList = false
} else {
// List follows: `topic:\n - sales`
inCategoryList = true
}
continue
}
}
// Collect tag items under `tags:`
if (inTags) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Collect list items under a category key
if (inCategoryList) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Handle labels: nested structure
if (inLabels) {
// Sub-key like ` relationship:` or ` topics:`
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
if (subKeyMatch) {
const key = subKeyMatch[1]
const inlineValue = subKeyMatch[2].trim()
if (METADATA_KEYS.has(key)) {
inLabelSubKey = false
continue
}
if (inlineValue) {
// Inline value like ` type: person`
tags.push(inlineValue)
inLabelSubKey = false
} else {
// Array follows
inLabelSubKey = true
}
continue
}
// Array item under a sub-key like ` - value`
if (inLabelSubKey) {
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
if (itemMatch) {
tags.push(itemMatch[1].trim())
}
}
}
}
return tags
}

View file

@ -237,6 +237,34 @@
flex-shrink: 0;
}
/* Tag pills row shown between toolbar and editor content */
.tag-pills-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px 12px;
border-bottom: 1px solid var(--border);
background-color: var(--background);
flex-shrink: 0;
max-height: 4.5em;
overflow: hidden;
}
.tag-pill {
font-size: 11px;
line-height: 18px;
padding: 0 8px;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
white-space: nowrap;
user-select: none;
}
.dark .tag-pill {
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
}
.editor-toolbar .separator {
width: 1px;
height: 1.5rem;