Merge pull request #565 from rowboatlabs/dev

Dev
This commit is contained in:
arkml 2026-05-19 21:35:00 +05:30 committed by GitHub
commit aba65843c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 4682 additions and 415 deletions

View file

@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' |
`buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context). `buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context).
This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)".
### Run flow (`runLiveNoteAgent`) ### Run flow (`runLiveNoteAgent`)
@ -254,17 +254,15 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
--- ---
## Daily-Note Template & Migrations ## Default Note Policy
`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals. Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block.
**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: **Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start:
- File missing → fresh write at canonical version. - File missing → mark processed and do nothing.
- File at-or-above canonical → no-op. - File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
- File below canonical → rename existing to `Today.md.bkp.<ISO-stamp>` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body). - Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced.
--- ---
@ -393,7 +391,7 @@ Conventions:
| Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | | Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` |
| Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` | | Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` |
| Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` | | Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` |
| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | | Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` |
| Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` |
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |

View file

@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js'; import { API_URL } from '@x/core/dist/config/env.js';
@ -482,6 +483,20 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => { 'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts); return workspace.remove(args.path, args.opts);
}, },
'gmail:getImportant': async (_event, args) => {
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
},
'gmail:getEverythingElse': async (_event, args) => {
return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
},
'gmail:triggerSync': async () => {
triggerGmailSync();
return {};
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};
},
'mcp:listTools': async (_event, args) => { 'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor); return mcpCore.listTools(args.serverName, args.cursor);
}, },

View file

@ -0,0 +1,41 @@
# Rowboat Design Language
Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing.
## Principles
1. **Calm density**
Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy.
2. **Command first**
Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states.
3. **Visible work state**
AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners.
4. **Notes as the canvas**
The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces.
5. **Neutral precision**
The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states.
## Tokens
- Radius: `8px` for controls and cards, smaller where density matters.
- Backgrounds: dev defaults in light and dark mode.
- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them.
- Shadows: reserved for the composer, menus, dialogs, and active segmented controls.
- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking.
- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts.
## Core Surfaces
- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette.
- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill.
- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal.
- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable.
- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect.
## Launch Positioning
The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website.

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,8 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { LiveNotesView } from '@/components/live-notes-view'; import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view'; import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { MeetingsView } from '@/components/meetings-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
Conversation, Conversation,
@ -176,8 +178,10 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__' const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const MEETINGS_TAB_PATH = '__rowboat_meetings__'
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
const EMAIL_TAB_PATH = '__rowboat_email__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) => const clampNumber = (value: number, min: number, max: number) =>
@ -307,8 +311,10 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH
const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH
const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH
const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => { const getSuggestedTopicTargetFolder = (category?: string) => {
@ -556,7 +562,9 @@ type ViewState =
| { type: 'graph' } | { type: 'graph' }
| { type: 'task'; name: string } | { type: 'task'; name: string }
| { type: 'suggested-topics' } | { type: 'suggested-topics' }
| { type: 'meetings' }
| { type: 'live-notes' } | { type: 'live-notes' }
| { type: 'email' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean { function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false if (a.type !== b.type) return false
@ -570,12 +578,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target. * malformed or names an unknown target.
* *
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|live-notes>&... * Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|meetings|live-notes>&...
* file: ?type=file&path=knowledge/foo.md * file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional) * chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph * graph: ?type=graph
* task: ?type=task&name=daily-brief * task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics * suggested-topics: ?type=suggested-topics
* meetings: ?type=meetings
* live-notes: ?type=live-notes * live-notes: ?type=live-notes
*/ */
function parseDeepLink(input: string): ViewState | null { function parseDeepLink(input: string): ViewState | null {
@ -601,6 +610,8 @@ function parseDeepLink(input: string): ViewState | null {
} }
case 'suggested-topics': case 'suggested-topics':
return { type: 'suggested-topics' } return { type: 'suggested-topics' }
case 'meetings':
return { type: 'meetings' }
case 'live-notes': case 'live-notes':
return { type: 'live-notes' } return { type: 'live-notes' }
default: default:
@ -652,7 +663,7 @@ function ContentHeader({
const isCollapsed = state === "collapsed" const isCollapsed = state === "collapsed"
return ( return (
<header <header
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar overflow-hidden" className="rowboat-titlebar titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar overflow-hidden"
style={{ style={{
paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12, paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12,
paddingRight: 12, paddingRight: 12,
@ -708,13 +719,18 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false) const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false) const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [isMeetingsOpen, setIsMeetingsOpen] = useState(false)
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false) const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false) const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
const [isEmailOpen, setIsEmailOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ const [expandedFrom, setExpandedFrom] = useState<{
path: string | null path: string | null
graph: boolean graph: boolean
suggestedTopics: boolean suggestedTopics: boolean
meetings: boolean
liveNotes: boolean liveNotes: boolean
bgTasks: boolean
email: boolean
} | null>(null) } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -1039,8 +1055,10 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => { const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View' if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isMeetingsTabPath(tab.path)) return 'Meetings'
if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isLiveNotesTabPath(tab.path)) return 'Live notes'
if (isBgTasksTabPath(tab.path)) return 'Background tasks' if (isBgTasksTabPath(tab.path)) return 'Background tasks'
if (isEmailTabPath(tab.path)) return 'Email'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2753,7 +2771,7 @@ function App() {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
@ -2762,7 +2780,7 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay]) }, [fileTabs, dismissBrowserOverlay])
@ -2781,32 +2799,62 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isSuggestedTopicsTabPath(tab.path)) { if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return return
} }
if (isLiveNotesTabPath(tab.path)) { if (isLiveNotesTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
return return
} }
if (isBgTasksTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
return
}
if (isMeetingsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
return
}
if (isEmailTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
return
}
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
const closeFileTab = useCallback((tabId: string) => { const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId) const closingTab = fileTabs.find(t => t.id === tabId)
if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path)
@ -2829,7 +2877,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
@ -2843,21 +2891,48 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) { } else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (isMeetingsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
} else if (isLiveNotesTabPath(newActiveTab.path)) { } else if (isLiveNotesTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (isBgTasksTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
setIsEmailOpen(false)
} else if (isEmailTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -2888,12 +2963,15 @@ function App() {
dismissBrowserOverlay() dismissBrowserOverlay()
handleNewChat() handleNewChat()
// Left-pane "new chat" should always open full chat view. // Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
meetings: isMeetingsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
bgTasks: isBgTasksOpen,
email: isEmailOpen,
}) })
} else { } else {
setExpandedFrom(null) setExpandedFrom(null)
@ -2902,8 +2980,8 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen]) }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context. // Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => { const handleNewChatTabInSidebar = useCallback(() => {
@ -3035,12 +3113,15 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => { const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return // Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) {
setExpandedFrom({ setExpandedFrom({
path: selectedPath, path: selectedPath,
graph: isGraphOpen, graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen, suggestedTopics: isSuggestedTopicsOpen,
meetings: isMeetingsOpen,
liveNotes: isLiveNotesOpen, liveNotes: isLiveNotesOpen,
bgTasks: isBgTasksOpen,
email: isEmailOpen,
}) })
} }
dismissBrowserOverlay() dismissBrowserOverlay()
@ -3048,27 +3129,51 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => { const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) { if (expandedFrom) {
if (expandedFrom.graph) { if (expandedFrom.graph) {
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.suggestedTopics) { } else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
} else if (expandedFrom.meetings) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
} else if (expandedFrom.liveNotes) { } else if (expandedFrom.liveNotes) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
} else if (expandedFrom.bgTasks) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(true)
setIsEmailOpen(false)
} else if (expandedFrom.email) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
} else if (expandedFrom.path) { } else if (expandedFrom.path) {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedPath(expandedFrom.path) setSelectedPath(expandedFrom.path)
} }
setExpandedFrom(null) setExpandedFrom(null)
@ -3078,12 +3183,14 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => { const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isEmailOpen) return { type: 'email' }
if (isMeetingsOpen) return { type: 'meetings' }
if (isLiveNotesOpen) return { type: 'live-notes' } if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath } if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' } if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId } return { type: 'chat', runId }
}, [selectedBackgroundTask, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1] const last = stack[stack.length - 1]
@ -3151,6 +3258,17 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureMeetingsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isMeetingsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: MEETINGS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const ensureBgTasksFileTab = useCallback(() => { const ensureBgTasksFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path)) const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path))
if (existing) { if (existing) {
@ -3162,12 +3280,38 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureEmailFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isEmailTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: EMAIL_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const openEmailView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setIsEmailOpen(true)
ensureEmailFileTab()
}, [ensureEmailFileTab])
const openBgTasksView = useCallback(() => { const openBgTasksView = useCallback(() => {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
@ -3175,6 +3319,21 @@ function App() {
ensureBgTasksFileTab() ensureBgTasksFileTab()
}, [ensureBgTasksFileTab]) }, [ensureBgTasksFileTab])
const openMeetingsView = useCallback(() => {
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
ensureMeetingsFileTab()
}, [ensureMeetingsFileTab])
const applyViewState = useCallback(async (view: ViewState) => { const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) { switch (view.type) {
case 'file': case 'file':
@ -3184,7 +3343,7 @@ function App() {
// visible in the middle pane. // visible in the middle pane.
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files. // Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file. // Only exit chat-only maximize, because that would hide the selected file.
@ -3199,7 +3358,7 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -3212,7 +3371,7 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
@ -3225,9 +3384,23 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true) setIsSuggestedTopicsOpen(true)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
ensureSuggestedTopicsFileTab() ensureSuggestedTopicsFileTab()
return return
case 'meetings':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(true)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
ensureMeetingsFileTab()
return
case 'live-notes': case 'live-notes':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3236,9 +3409,26 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(false)
setIsLiveNotesOpen(true) setIsLiveNotesOpen(true)
ensureLiveNotesFileTab() ensureLiveNotesFileTab()
return return
case 'email':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsMeetingsOpen(false)
setIsLiveNotesOpen(false)
setIsBgTasksOpen(false)
setIsEmailOpen(true)
ensureEmailFileTab()
return
case 'chat': case 'chat':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -3247,7 +3437,7 @@ function App() {
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false) setIsSuggestedTopicsOpen(false)
setIsLiveNotesOpen(false); setIsBgTasksOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -3255,7 +3445,7 @@ function App() {
} }
return return
} }
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -3577,7 +3767,7 @@ function App() {
}, []) }, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view // Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask && !isBrowserOpen const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') { if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3650,19 +3840,23 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => { const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey const mod = e.metaKey || e.ctrlKey
if (!mod) return if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && isChatSidebarOpen) const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane) ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left' : 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen)
const selectedKnowledgePath = isGraphOpen const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH ? GRAPH_TAB_PATH
: isSuggestedTopicsOpen : isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH ? SUGGESTED_TOPICS_TAB_PATH
: isMeetingsOpen
? MEETINGS_TAB_PATH
: isLiveNotesOpen : isLiveNotesOpen
? LIVE_NOTES_TAB_PATH ? LIVE_NOTES_TAB_PATH
: isBgTasksOpen : isBgTasksOpen
? BG_TASKS_TAB_PATH ? BG_TASKS_TAB_PATH
: isEmailOpen
? EMAIL_TAB_PATH
: selectedPath : selectedPath
const targetFileTabId = activeFileTabId ?? ( const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath selectedKnowledgePath
@ -3717,7 +3911,7 @@ function App() {
} }
document.addEventListener('keydown', handleTabKeyDown) document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { if (kind === 'file') {
@ -3742,7 +3936,7 @@ function App() {
}), }),
}, },
})) }))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -3868,14 +4062,14 @@ function App() {
}, },
openGraph: () => { openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view. // From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
openBases: () => { openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false) setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
} }
@ -4471,7 +4665,7 @@ function App() {
const selectedTask = selectedBackgroundTask const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask) ? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null : null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => { const openMarkdownTabs = React.useMemo(() => {
@ -4488,11 +4682,11 @@ function App() {
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => { <SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen) { if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
} }
}}> }}>
<div className="flex h-svh w-full overflow-hidden"> <div className="rowboat-shell flex h-svh w-full overflow-hidden">
{/* Content sidebar with SidebarProvider for collapse functionality */} {/* Content sidebar with SidebarProvider for collapse functionality */}
<SidebarProvider <SidebarProvider
style={{ style={{
@ -4521,7 +4715,7 @@ function App() {
onNewChat: handleNewChatTab, onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive() cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
} }
@ -4532,7 +4726,7 @@ function App() {
return return
} }
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad) loadRun(runIdToLoad)
return return
@ -4556,14 +4750,14 @@ function App() {
} else { } else {
// Only one tab, reset it to new chat // Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }]) setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
handleNewChat() handleNewChat()
} else { } else {
void navigateToView({ type: 'chat', runId: null }) void navigateToView({ type: 'chat', runId: null })
} }
} }
} else if (runId === runIdToDelete) { } else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat() handleNewChat()
} else { } else {
@ -4583,20 +4777,19 @@ function App() {
selectedBackgroundTask={selectedBackgroundTask} selectedBackgroundTask={selectedBackgroundTask}
onNewChat={handleNewChatTab} onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)} onOpenSearch={() => setIsSearchOpen(true)}
meetingState={meetingTranscription.state}
meetingSummarizing={meetingSummarizing}
meetingAvailable={voiceAvailable}
onToggleMeeting={() => { void handleToggleMeeting() }}
isSearchOpen={isSearchOpen} isSearchOpen={isSearchOpen}
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
isBrowserOpen={isBrowserOpen} isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser} onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen} isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isMeetingsOpen={isMeetingsOpen}
onOpenMeetings={openMeetingsView}
isLiveNotesOpen={isLiveNotesOpen} isLiveNotesOpen={isLiveNotesOpen}
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
isBgTasksOpen={isBgTasksOpen} isBgTasksOpen={isBgTasksOpen}
onOpenBgTasks={openBgTasksView} onOpenBgTasks={openBgTasksView}
isEmailOpen={isEmailOpen}
onOpenEmail={openEmailView}
/> />
<SidebarInset <SidebarInset
className={cn( className={cn(
@ -4616,7 +4809,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -4624,7 +4817,7 @@ function App() {
getTabId={(t) => t.id} getTabId={(t) => t.id}
onSwitchTab={switchFileTab} onSwitchTab={switchFileTab}
onCloseTab={closeFileTab} onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/> />
) : ( ) : (
<TabBar <TabBar
@ -4677,7 +4870,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent> <TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4692,7 +4885,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent> <TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4707,7 +4900,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent> <TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -4740,6 +4933,15 @@ function App() {
}} }}
/> />
</div> </div>
) : isMeetingsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<MeetingsView
onOpenNote={(path) => navigateToFile(path)}
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
meetingState={meetingTranscription.state}
meetingSummarizing={meetingSummarizing}
/>
</div>
) : isLiveNotesOpen ? ( ) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<LiveNotesView <LiveNotesView
@ -4760,6 +4962,10 @@ function App() {
}} }}
/> />
</div> </div>
) : isEmailOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<EmailView />
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? ( ) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView <BasesView
@ -5057,7 +5263,7 @@ function App() {
})} })}
</div> </div>
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg"> <div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" /> <div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
<div className="mx-auto w-full max-w-4xl px-4"> <div className="mx-auto w-full max-w-4xl px-4">
{!hasConversation && ( {!hasConversation && (

View file

@ -18,6 +18,7 @@ import { toast } from '@/lib/toast'
import type { ConversationItem } from '@/lib/chat-conversation' import type { ConversationItem } from '@/lib/chat-conversation'
import { runLogToConversation } from '@/lib/run-to-conversation' import { runLogToConversation } from '@/lib/run-to-conversation'
import { CompactConversation } from '@/components/compact-conversation' import { CompactConversation } from '@/components/compact-conversation'
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up) // Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up)
@ -560,9 +561,7 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
) : viewSource ? ( ) : viewSource ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre> <pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
) : ( ) : (
<Streamdown className="prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <RichMarkdownViewer content={body} />
{body}
</Streamdown>
)} )}
</div> </div>
</div> </div>

View file

@ -434,7 +434,7 @@ function ChatInputInner({
}, [addFiles, isActive]) }, [addFiles, isActive])
return ( return (
<div className="rounded-lg border border-border bg-background shadow-none"> <div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3"> <div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((attachment) => { {attachments.map((attachment) => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,778 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event'
import { cn } from '@/lib/utils'
import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription'
const MEETINGS_ROOT = 'knowledge/Meetings'
const CALENDAR_DIR = 'calendar_sync'
const UPCOMING_MAX_DAYS = 4 // today + next 3
type MeetingNoteRow = {
path: string
name: string
dateLabel: string
mtimeMs: number
}
type MeetingsViewProps = {
onOpenNote: (path: string) => void
onTakeMeetingNotes: () => void
meetingState: MeetingTranscriptionState
meetingSummarizing?: boolean
}
function isMeetingPath(path: string | undefined): boolean {
return typeof path === 'string' && (path === MEETINGS_ROOT || path.startsWith(`${MEETINGS_ROOT}/`))
}
function isCalendarPath(path: string | undefined): boolean {
return typeof path === 'string' && (path === CALENDAR_DIR || path.startsWith(`${CALENDAR_DIR}/`))
}
type RawCalendarEvent = {
id?: string
summary?: string
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
htmlLink?: string
status?: string
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
hangoutLink?: string
conferenceLink?: string
}
type UpcomingEvent = {
id: string
summary: string
start: Date
end: Date | null
isAllDay: boolean
location: string | null
htmlLink: string | null
conferenceLink: string | null
source: string // workspace path to the calendar_sync JSON
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
dateKey: string // YYYY-MM-DD (local)
}
type DayGroup = {
dateKey: string
date: Date // local start-of-day
events: UpcomingEvent[]
}
function startOfDay(d: Date): Date {
const out = new Date(d)
out.setHours(0, 0, 0, 0)
return out
}
function addDays(d: Date, n: number): Date {
const out = new Date(d)
out.setDate(out.getDate() + n)
return out
}
function localDateKey(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// Parse an all-day calendar date string ("YYYY-MM-DD") into a local Date at midnight.
function parseAllDayDate(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEvent | null {
if (raw.status === 'cancelled') return null
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
if (declined) return null
const allDayStart = raw.start?.date
const timedStart = raw.start?.dateTime
const isAllDay = !timedStart && Boolean(allDayStart)
let start: Date | null = null
let end: Date | null = null
if (timedStart) {
start = new Date(timedStart)
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
} else if (allDayStart) {
start = parseAllDayDate(allDayStart)
// Google's all-day end is exclusive (next day at 00:00) — keep as-is.
end = raw.end?.date ? parseAllDayDate(raw.end.date) : null
}
if (!start || Number.isNaN(start.getTime())) return null
const conferenceLink = extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null
return {
id: raw.id ?? sourcePath,
summary: raw.summary?.trim() || '(No title)',
start,
end,
isAllDay,
location: raw.location?.trim() || null,
htmlLink: raw.htmlLink ?? null,
conferenceLink,
source: sourcePath,
rawStart: raw.start,
rawEnd: raw.end,
dateKey: localDateKey(start),
}
}
function triggerMeetingCapture(event: UpcomingEvent, openConference: boolean) {
window.__pendingCalendarEvent = {
summary: event.summary,
start: event.rawStart,
end: event.rawEnd,
location: event.location ?? undefined,
htmlLink: event.htmlLink ?? undefined,
conferenceLink: event.conferenceLink ?? undefined,
source: event.source,
}
if (openConference && event.conferenceLink) {
window.open(event.conferenceLink, '_blank')
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
}
// Always show today (anchor). For days within the window after today, include
// only those that actually have events — skip empty days.
function selectVisibleDays(allDays: DayGroup[]): DayGroup[] {
if (allDays.length === 0) return []
const out: DayGroup[] = [allDays[0]]
const cap = Math.min(allDays.length, UPCOMING_MAX_DAYS)
for (let i = 1; i < cap; i++) {
if (allDays[i].events.length > 0) out.push(allDays[i])
}
return out
}
function buildDayWindow(now: Date): DayGroup[] {
const today = startOfDay(now)
return Array.from({ length: UPCOMING_MAX_DAYS }, (_, i) => {
const date = addDays(today, i)
return { dateKey: localDateKey(date), date, events: [] }
})
}
function formatEventTimeRange(event: UpcomingEvent): string {
if (event.isAllDay) return 'All day'
const start = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (!event.end) return start
// If start and end are on different days, show date+time on both ends.
const sameDay = localDateKey(event.start) === localDateKey(event.end)
if (!sameDay) {
const startLong = event.start.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
const endLong = event.end.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
return `${startLong} ${endLong}`
}
const end = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return `${start} ${end}`
}
function UpcomingEvents() {
const [events, setEvents] = useState<UpcomingEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
const loadEvents = useCallback(async () => {
setLoading(true)
try {
const exists = await window.ipc.invoke('workspace:exists', { path: CALENDAR_DIR })
if (!exists.exists) {
setEvents([])
setError(null)
return
}
const entries = await window.ipc.invoke('workspace:readdir', {
path: CALENDAR_DIR,
opts: { recursive: false, includeHidden: false, includeStats: false },
})
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
const now = new Date()
const todayStart = startOfDay(now)
const windowEnd = addDays(todayStart, UPCOMING_MAX_DAYS) // exclusive
const settled = await Promise.allSettled(
jsonEntries.map(async (entry): Promise<UpcomingEvent | null> => {
const result = await window.ipc.invoke('workspace:readFile', {
path: entry.path,
encoding: 'utf8',
})
const raw = JSON.parse(result.data) as RawCalendarEvent
const ev = normalizeEvent(raw, entry.path)
if (!ev) return null
// Event must overlap the [now, windowEnd) range — i.e. not already ended,
// and not start after the window closes.
const effectiveEnd = ev.end ?? (ev.isAllDay ? addDays(ev.start, 1) : ev.start)
if (effectiveEnd <= now) return null
if (ev.start >= windowEnd) return null
return ev
}),
)
const collected: UpcomingEvent[] = []
for (const r of settled) {
if (r.status === 'fulfilled' && r.value) collected.push(r.value)
}
collected.sort((a, b) => {
if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1
return a.start.getTime() - b.start.getTime()
})
setEvents(collected)
setError(null)
} catch (err) {
console.error('Failed to load upcoming events:', err)
setError('Could not load upcoming events.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadEvents()
}, [loadEvents, refreshTick])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
setRefreshTick((t) => t + 1)
}, 250)
}
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isCalendarPath(event.path)) scheduleReload()
break
case 'moved':
if (isCalendarPath(event.from) || isCalendarPath(event.to)) scheduleReload()
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isCalendarPath)) scheduleReload()
break
}
})
// Refresh on the hour so day labels and "ended" filtering stay current.
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000)
return () => {
cleanup()
clearInterval(tick)
if (timeout) clearTimeout(timeout)
}
}, [])
const visibleDays = useMemo(() => {
const window = buildDayWindow(new Date())
const byKey = new Map(window.map((d) => [d.dateKey, d]))
for (const ev of events) {
byKey.get(ev.dateKey)?.events.push(ev)
}
return selectVisibleDays(window)
}, [events])
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
const now = new Date()
const todayKey = localDateKey(now)
return (
<section className="border-b border-border/60 px-6 pb-6 pt-5">
<div className="mx-auto w-full max-w-[760px]">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
Coming up
</h3>
{loading && events.length === 0 ? null : (
<span
className="text-[11px] uppercase tracking-wider"
style={{ color: 'var(--gm-text-faint)' }}
>
{totalVisible} {totalVisible === 1 ? 'event' : 'events'}
</span>
)}
</div>
{loading && events.length === 0 ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="py-4 text-sm text-muted-foreground">{error}</div>
) : (
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--gm-border)', background: 'var(--gm-bg)' }}
>
{visibleDays.map((day, idx) => (
<UpcomingDayRow
key={day.dateKey}
day={day}
isToday={day.dateKey === todayKey}
isLast={idx === visibleDays.length - 1}
/>
))}
</div>
)}
</div>
</section>
)
}
function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) {
const dayNum = day.date.getDate()
const month = day.date.toLocaleDateString([], { month: 'short' })
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
return (
<div
className="grid"
style={{
gridTemplateColumns: '96px 1fr',
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
}}
>
<div className="flex items-start gap-2 px-4 py-4">
<span
className="leading-none"
style={{ fontSize: 30, fontWeight: 400, color: 'var(--gm-text-strong)' }}
>
{dayNum}
</span>
<span className="flex flex-col leading-tight">
<span
className="flex items-center gap-1"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--gm-text)' }}
>
{month}
{isToday ? (
<span
aria-hidden
className="inline-block rounded-full"
style={{ width: 5, height: 5, background: 'var(--gm-accent)' }}
/>
) : null}
</span>
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
</span>
</div>
<div className="flex flex-col py-3 pr-3">
{day.events.length === 0 ? (
<div
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
style={{ color: 'var(--gm-text-faint)', minHeight: 40 }}
>
<span aria-hidden className="self-stretch shrink-0" style={{ width: 3 }} />
<span>{isToday ? 'No events today' : 'No events'}</span>
</div>
) : (
day.events.map((ev) => <UpcomingEventItem key={ev.id} event={ev} />)
)}
</div>
</div>
)
}
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
const handleOpen = useCallback(() => {
if (event.htmlLink) window.open(event.htmlLink, '_blank')
}, [event.htmlLink])
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
return (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen()
}
}}
title={titleAndLocation}
className={cn(
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
)}
style={{ color: 'var(--gm-text)', minHeight: 40 }}
>
<span
aria-hidden
className="self-stretch rounded-full"
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
/>
<span className="min-w-0 flex-1">
<span
className="block truncate"
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
>
{event.summary}
</span>
<span
className="mt-0.5 block truncate"
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
>
{formatEventTimeRange(event)}
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
</span>
</span>
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{event.conferenceLink ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
onNotesOnly={() => triggerMeetingCapture(event, false)}
/>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
onMouseDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<Mic className="size-3" />
Take notes
</button>
)}
</div>
</div>
)
}
function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
onJoinAndNotes: () => void
onNotesOnly: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
const target = e.target
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div
ref={ref}
style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
className="inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<Video className="size-3" />
Join & take notes
</button>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
aria-label="More meeting options"
className="inline-flex items-center justify-center px-1.5 py-1 transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderLeft: 'none',
borderTopRightRadius: 6,
borderBottomRightRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
>
<ChevronDown className="size-3" />
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 50,
background: 'var(--gm-bg-card)',
border: '1px solid var(--gm-border)',
borderRadius: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
minWidth: 144,
overflow: 'hidden',
}}
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
className="flex w-full items-center gap-1 px-2 py-1.5 text-xs"
style={{ background: 'transparent', color: 'var(--gm-text)', whiteSpace: 'nowrap', border: 'none' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-row-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Mic className="size-3" />
Take notes only
</button>
</div>
)}
</div>
)
}
function formatMeetingName(name: string): string {
return name.replace(/\.md$/i, '').replace(/_/g, ' ')
}
function formatDateLabel(label: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(label)) return label || '—'
const date = new Date(`${label}T00:00:00`)
if (Number.isNaN(date.getTime())) return label
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function getMeetingButtonLabel(state: MeetingTranscriptionState): string {
switch (state) {
case 'connecting':
return 'Starting...'
case 'recording':
return 'Stop recording'
case 'stopping':
return 'Stopping...'
case 'idle':
default:
return 'Take meeting notes'
}
}
export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, meetingSummarizing = false }: MeetingsViewProps) {
const [notes, setNotes] = useState<MeetingNoteRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const exists = await window.ipc.invoke('workspace:exists', { path: MEETINGS_ROOT })
if (!exists.exists) {
setNotes([])
setError(null)
return
}
const entries = await window.ipc.invoke('workspace:readdir', {
path: MEETINGS_ROOT,
opts: {
recursive: true,
includeHidden: false,
includeStats: true,
},
})
const rows = entries
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
.map((entry) => {
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
const parts = relative.split('/')
const dateFolder = parts.find((part) => /^\d{4}-\d{2}-\d{2}$/.test(part)) ?? ''
return {
path: entry.path,
name: formatMeetingName(entry.name),
dateLabel: formatDateLabel(dateFolder),
mtimeMs: entry.stat?.mtimeMs ?? 0,
} satisfies MeetingNoteRow
})
.sort((a, b) => {
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs
return b.path.localeCompare(a.path)
})
setNotes(rows)
setError(null)
} catch (err) {
console.error('Failed to load meetings:', err)
setError('Could not load meeting notes.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanup = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isMeetingPath(event.path)) scheduleReload()
break
case 'moved':
if (isMeetingPath(event.from) || isMeetingPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isMeetingPath)) {
scheduleReload()
}
break
}
})
return () => {
cleanup()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const isBusy = meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing
const isRecording = meetingState === 'recording'
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Mic className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Meetings</h2>
</div>
<Button
type="button"
size="sm"
variant={isRecording ? 'destructive' : 'default'}
disabled={isBusy}
onClick={onTakeMeetingNotes}
>
{meetingSummarizing || meetingState === 'connecting' || meetingState === 'stopping' ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : isRecording ? (
<Square className="mr-2 size-3.5" />
) : (
<Mic className="mr-2 size-4" />
)}
{meetingSummarizing ? 'Generating notes...' : getMeetingButtonLabel(meetingState)}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Upcoming events and meeting notes.
</p>
</div>
<div className="flex-1 overflow-auto">
<UpcomingEvents />
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center justify-center px-8 py-10 text-center text-sm text-muted-foreground">
{error}
</div>
) : notes.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-10 text-center">
<div className="rounded-full bg-muted p-3">
<Mic className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No meeting notes yet. Use <strong>Take meeting notes</strong> to start one.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[56%]" />
<col className="w-[20%]" />
<col className="w-[24%]" />
</colgroup>
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Date</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Updated</th>
</tr>
</thead>
<tbody>
{notes.map((note) => (
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
>
<span className="block truncate">{note.name}</span>
</button>
</td>
<td className="px-4 py-3 align-top text-sm text-muted-foreground">{note.dateLabel}</td>
<td className="px-4 py-3 align-top text-sm text-muted-foreground">
{note.mtimeMs > 0 ? (formatRelativeTime(new Date(note.mtimeMs).toISOString()) || '—') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,106 @@
import { useEffect } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { TableKit } from '@tiptap/extension-table'
import { Markdown } from 'tiptap-markdown'
import { TaskBlockExtension } from '@/extensions/task-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { WikiLink } from '@/extensions/wiki-link'
import '@/styles/editor.css'
const BLANK_LINE_MARKER = '\u200B'
function preprocessMarkdown(markdown: string): string {
return markdown.replace(/\n{3,}/g, (match) => {
const emptyParagraphs = match.length - 2
let result = '\n\n'
for (let i = 0; i < emptyParagraphs; i += 1) {
result += BLANK_LINE_MARKER + '\n\n'
}
return result
})
}
export function RichMarkdownViewer({ content }: { content: string }) {
const editor = useEditor({
editable: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
}),
Image.configure({
inline: false,
allowBase64: true,
HTMLAttributes: {
class: 'editor-image',
},
}),
TaskBlockExtension,
PromptBlockExtension,
ImageBlockExtension,
EmbedBlockExtension,
IframeBlockExtension,
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,
WikiLink,
TaskList,
TaskItem.configure({
nested: true,
}),
TableKit.configure({
table: { resizable: false },
}),
Markdown.configure({
html: true,
breaks: true,
tightLists: false,
transformCopiedText: false,
transformPastedText: false,
}),
],
content: preprocessMarkdown(content),
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none',
},
},
})
useEffect(() => {
if (!editor) return
editor.chain().setMeta('addToHistory', false).setContent(preprocessMarkdown(content)).run()
}, [content, editor])
return (
<div className="tiptap-editor rich-markdown-viewer">
<EditorContent editor={editor} />
</div>
)
}

View file

@ -27,6 +27,7 @@ import {
Lightbulb, Lightbulb,
ListChecks, ListChecks,
LoaderIcon, LoaderIcon,
Mail,
Settings, Settings,
Square, Square,
Trash2, Trash2,
@ -99,7 +100,6 @@ import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time" import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
import { useBilling } from "@/hooks/useBilling" import { useBilling } from "@/hooks/useBilling"
import { ServiceEvent } from "@x/shared/src/service-events.js" import { ServiceEvent } from "@x/shared/src/service-events.js"
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
import z from "zod" import z from "zod"
interface TreeNode { interface TreeNode {
@ -216,20 +216,19 @@ type SidebarContentPanelProps = {
selectedBackgroundTask?: string | null selectedBackgroundTask?: string | null
onNewChat?: () => void onNewChat?: () => void
onOpenSearch?: () => void onOpenSearch?: () => void
meetingState?: MeetingTranscriptionState
meetingSummarizing?: boolean
meetingAvailable?: boolean
onToggleMeeting?: () => void
isSearchOpen?: boolean isSearchOpen?: boolean
isMeetingActionActive?: boolean
isBrowserOpen?: boolean isBrowserOpen?: boolean
onToggleBrowser?: () => void onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void onOpenSuggestedTopics?: () => void
isMeetingsOpen?: boolean
onOpenMeetings?: () => void
isLiveNotesOpen?: boolean isLiveNotesOpen?: boolean
onOpenLiveNotes?: () => void onOpenLiveNotes?: () => void
isBgTasksOpen?: boolean isBgTasksOpen?: boolean
onOpenBgTasks?: () => void onOpenBgTasks?: () => void
isEmailOpen?: boolean
onOpenEmail?: () => void
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [ const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -478,20 +477,19 @@ export function SidebarContentPanel({
selectedBackgroundTask, selectedBackgroundTask,
onNewChat, onNewChat,
onOpenSearch, onOpenSearch,
meetingState = 'idle',
meetingSummarizing = false,
meetingAvailable = false,
onToggleMeeting,
isSearchOpen = false, isSearchOpen = false,
isMeetingActionActive = false,
isBrowserOpen = false, isBrowserOpen = false,
onToggleBrowser, onToggleBrowser,
isSuggestedTopicsOpen = false, isSuggestedTopicsOpen = false,
onOpenSuggestedTopics, onOpenSuggestedTopics,
isMeetingsOpen = false,
onOpenMeetings,
isLiveNotesOpen = false, isLiveNotesOpen = false,
onOpenLiveNotes, onOpenLiveNotes,
isBgTasksOpen = false, isBgTasksOpen = false,
onOpenBgTasks, onOpenBgTasks,
isEmailOpen = false,
onOpenEmail,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection() const { activeSection, setActiveSection } = useSidebarSection()
@ -504,11 +502,12 @@ export function SidebarContentPanel({
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null) const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected) const { billing } = useBilling(isRowboatConnected)
const isMeetingQuickActionSelected = isMeetingActionActive const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => { const handleRowboatLogin = useCallback(async () => {
try { try {
@ -567,13 +566,13 @@ export function SidebarContentPanel({
}, []) }, [])
return ( return (
<Sidebar className="border-r-0" {...props}> <Sidebar className="rowboat-sidebar border-r-0" {...props}>
<SidebarHeader className="titlebar-drag-region"> <SidebarHeader className="titlebar-drag-region">
{/* Top spacer to clear the traffic lights + fixed toggle row */} {/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" /> <div className="h-8" />
{/* Tab switcher - centered below the traffic lights row */} {/* Tab switcher - centered below the traffic lights row */}
<div className="flex items-center px-2 py-1.5"> <div className="flex items-center px-2 py-1.5">
<div className="titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5"> <div className="rowboat-section-switcher titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
{sectionTabs.map((tab) => ( {sectionTabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@ -591,7 +590,7 @@ export function SidebarContentPanel({
</div> </div>
</div> </div>
{/* Quick action buttons */} {/* Quick action buttons */}
<div className="titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1"> <div className="rowboat-quick-actions titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
{onNewChat && ( {onNewChat && (
<button <button
type="button" type="button"
@ -617,41 +616,6 @@ export function SidebarContentPanel({
<span>Search</span> <span>Search</span>
</button> </button>
)} )}
{meetingAvailable && onToggleMeeting && (
<button
type="button"
onClick={onToggleMeeting}
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
isMeetingQuickActionSelected
? "bg-sidebar-accent"
: "hover:bg-sidebar-accent",
meetingState === 'recording'
? "text-red-500"
: isMeetingQuickActionSelected
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:text-sidebar-accent-foreground"
)}
>
{meetingSummarizing || meetingState === 'connecting' ? (
<LoaderIcon className="size-4 animate-spin" />
) : meetingState === 'recording' ? (
<Square className="size-4 animate-pulse" />
) : (
<Radio className="size-4" />
)}
<span>
{meetingSummarizing
? 'Generating notes…'
: meetingState === 'connecting'
? 'Starting…'
: meetingState === 'recording'
? 'Stop recording'
: 'Take meeting notes'}
</span>
</button>
)}
{onToggleBrowser && ( {onToggleBrowser && (
<button <button
type="button" type="button"
@ -697,6 +661,36 @@ export function SidebarContentPanel({
<span>Background tasks</span> <span>Background tasks</span>
</button> </button>
)} )}
{onOpenEmail && (
<button
type="button"
onClick={onOpenEmail}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isEmailQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Mail className="size-4" />
<span>Email</span>
</button>
)}
{onOpenMeetings && (
<button
type="button"
onClick={onOpenMeetings}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isMeetingsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Mic className="size-4" />
<span>Meetings</span>
</button>
)}
{onOpenLiveNotes && ( {onOpenLiveNotes && (
<button <button
type="button" type="button"
@ -1126,6 +1120,10 @@ function KnowledgeSection({
const treeContainerRef = React.useRef<HTMLDivElement | null>(null) const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null) const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<string | null>(null) const [renameTarget, setRenameTarget] = useState<string | null>(null)
const visibleTree = React.useMemo(
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
[tree],
)
useEffect(() => { useEffect(() => {
if (!selectedPath) return if (!selectedPath) return
@ -1154,7 +1152,7 @@ function KnowledgeSection({
cancelled = true cancelled = true
if (rafId !== null) cancelAnimationFrame(rafId) if (rafId !== null) cancelAnimationFrame(rafId)
} }
}, [selectedPath, expandedPaths, tree]) }, [selectedPath, expandedPaths, visibleTree])
// Folder clicks highlight the folder; file clicks clear folder highlight // Folder clicks highlight the folder; file clicks clear folder highlight
const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => { const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => {
@ -1236,7 +1234,7 @@ function KnowledgeSection({
<SidebarGroupContent className="flex-1 overflow-y-auto"> <SidebarGroupContent className="flex-1 overflow-y-auto">
<div ref={treeContainerRef}> <div ref={treeContainerRef}>
<SidebarMenu> <SidebarMenu>
{tree.map((item, index) => ( {visibleTree.map((item, index) => (
<Tree <Tree
key={index} key={index}
item={item} item={item}

View file

@ -37,7 +37,7 @@ export function TabBar<T>({
return ( return (
<div <div
className={cn( className={cn(
'flex flex-1 self-stretch min-w-0', 'rowboat-tabbar flex flex-1 self-stretch min-w-0',
layout === 'scroll' layout === 'scroll'
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' ? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'overflow-hidden' : 'overflow-hidden'
@ -57,7 +57,7 @@ export function TabBar<T>({
type="button" type="button"
onClick={() => onSwitchTab(tabId)} onClick={() => onSwitchTab(tabId)}
className={cn( className={cn(
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors', 'rowboat-tab titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]', layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
isActive isActive
? 'bg-background text-foreground' ? 'bg-background text-foreground'

View file

@ -42,8 +42,8 @@ function isSamePath(a: string, b: string | undefined): boolean {
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label * - Ticks every minute so callers using `formatRelativeTime` get a fresh label
* without the underlying data changing. * without the underlying data changing.
* *
* `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted * `notePath` may be either knowledge-relative (`Digest.md`) or workspace-rooted
* (`knowledge/Today.md`); the hook normalises internally. * (`knowledge/Digest.md`); the hook normalises internally.
*/ */
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult { export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null) const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)

View file

@ -2020,3 +2020,33 @@
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before { .dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.3);
} }
/* Read-only renderer used by surfaces that need rich blocks without editor chrome. */
.rich-markdown-viewer {
display: block;
overflow: visible;
min-height: auto;
}
.rich-markdown-viewer .ProseMirror {
max-width: none;
margin: 0;
padding: 0;
}
.rich-markdown-viewer .ProseMirror:focus {
outline: none;
}
.rich-markdown-viewer .ProseMirror .task-block-delete,
.rich-markdown-viewer .ProseMirror .image-block-delete,
.rich-markdown-viewer .ProseMirror .embed-block-delete,
.rich-markdown-viewer .ProseMirror .iframe-block-delete,
.rich-markdown-viewer .ProseMirror .chart-block-delete,
.rich-markdown-viewer .ProseMirror .table-block-delete,
.rich-markdown-viewer .ProseMirror .calendar-block-delete,
.rich-markdown-viewer .ProseMirror .email-block-delete,
.rich-markdown-viewer .ProseMirror .email-draft-block-delete,
.rich-markdown-viewer .ProseMirror .mermaid-block-delete {
display: none;
}

View file

@ -314,7 +314,7 @@ The agent always receives a \`**Trigger:**\` line in its run message telling it
- \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh. - \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh.
- \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive. - \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive.
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window pull a full snapshot from local data) and a *reactive* update (event integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans \`gmail_sync/\` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches. **When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window pull a full snapshot from local data) and a *reactive* update (event integrate one new signal). For example, an email digest can scan \`gmail_sync/\` for everything worth attention on a window run, then integrate one incoming thread on an event run without re-listing previously-seen threads. Same objective, two branches.
How to write it use plain conditional language inside the objective: How to write it use plain conditional language inside the objective:

View file

@ -1,9 +1,9 @@
/** /**
* The canonical writing style for content written into the user's knowledge * The canonical writing style for content written into the user's knowledge
* base. Imported by both the `doc-collab` skill (so Copilot picks it up on * base. Imported by the `doc-collab` skill (so Copilot picks it up on note
* note edits) and the live-note run-agent prompt (so background runs use the * edits), the live-note run-agent prompt, and the background-task run-agent
* same rules without having to load the skill on every fire). One source of * prompt (so background runs use the same rules without having to load the
* truth, two consumers. * skill on every fire).
* *
* If you change this guide, restart the dev server / rebuild both consumers * If you change this guide, restart the dev server / rebuild both consumers
* inline it at module load. * inline it at module load.
@ -113,6 +113,186 @@ Common note types and the target shape for each:
- **GitHub / issue digests**: \`- [<title>](<issue_url>) · <repo> · <state> · <updated>\`. - **GitHub / issue digests**: \`- [<title>](<issue_url>) · <repo> · <state> · <updated>\`.
- **Tweets / social digests**: \`- [<truncated text or topic>](<post_url>) · @<author> · <when>\`. - **Tweets / social digests**: \`- [<truncated text or topic>](<post_url>) · @<author> · <when>\`.
## Rich Markdown block formats
The renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, emails, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the user asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
### \`table\` — tabular data (JSON)
Use for: scoreboards, leaderboards, comparisons, multi-row status digests.
\`\`\`table
{
"title": "Top stories on Hacker News",
"columns": ["Rank", "Title", "Points", "Comments"],
"data": [
{"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312},
{"Rank": 2, "Title": "...", "Points": 530, "Comments": 144}
]
}
\`\`\`
Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`.
### \`chart\` — line / bar / pie chart (JSON)
Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning.
\`\`\`chart
{
"chart": "line",
"title": "USD/INR — last 7 days",
"x": "date",
"y": "rate",
"data": [
{"date": "2026-04-13", "rate": 83.41},
{"date": "2026-04-14", "rate": 83.38}
]
}
\`\`\`
Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`.
### \`mermaid\` — diagrams (raw Mermaid source)
Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps.
\`\`\`mermaid
graph LR
A[Project Alpha] --> B[Sarah Chen]
A --> C[Acme Corp]
B --> D[Q3 Launch]
\`\`\`
Body is plain Mermaid source no JSON wrapper.
### \`calendar\` — list of events (JSON)
Use for: upcoming meetings, agenda digests, day/week views.
\`\`\`calendar
{
"title": "Today",
"events": [
{
"summary": "1:1 with Sarah",
"start": {"dateTime": "2026-04-20T10:00:00-07:00"},
"end": {"dateTime": "2026-04-20T10:30:00-07:00"},
"location": "Zoom",
"conferenceLink": "https://zoom.us/j/..."
}
]
}
\`\`\`
Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool).
### \`emails\` — multi-thread email digest (JSON)
Use for: surfacing a compact inbox-style digest of several relevant threads.
\`\`\`emails
{
"title": "Q3 planning threads",
"emails": [
{
"subject": "Q3 launch readiness",
"from": "sarah@acme.com",
"date": "2026-04-19T16:42:00Z",
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
}
]
}
\`\`\`
Required: \`emails\` (array of \`email\` objects). Optional top-level: \`title\`.
### \`email\` — single email or thread digest (JSON)
Use for: surfacing one important thread latest message body, summary of prior context, optional draft reply.
\`\`\`email
{
"subject": "Q3 launch readiness",
"from": "sarah@acme.com",
"date": "2026-04-19T16:42:00Z",
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
}
\`\`\`
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
For digests of **many** threads, prefer an \`emails\` block or a compact markdown table — \`email\` is for one thread at a time.
### \`image\` — single image (JSON)
Use for: charts, screenshots, photos you have a URL or workspace path for.
\`\`\`image
{
"src": "https://example.com/forecast.png",
"alt": "Weather forecast",
"caption": "Bay Area · April 20"
}
\`\`\`
Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`.
### \`embed\` — YouTube / Figma / Tweet embed (JSON)
Use for: linking to a video, design, or tweet that should render inline.
\`\`\`embed
{
"provider": "youtube",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"caption": "Latest demo"
}
\`\`\`
Required: \`provider\` ("youtube" | "figma" | "tweet" | "generic"), \`url\`. Optional: \`caption\`.
### \`iframe\` — arbitrary embedded webpage (JSON)
Use for: live dashboards, status pages, trackers anything that has its own webpage and benefits from being live, not snapshotted.
\`\`\`iframe
{
"url": "https://status.example.com",
"title": "Service status",
"height": 600
}
\`\`\`
Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (2401600), \`allow\` (Permissions-Policy string).
### \`transcript\` — long transcript (JSON)
Use for: meeting transcripts, voice-note dumps bodies that benefit from a collapsible UI.
\`\`\`transcript
{"transcript": "[00:00] Speaker A: Welcome everyone..."}
\`\`\`
Required: \`transcript\` (string).
### \`prompt\` — starter Copilot prompt (YAML)
Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot.
\`\`\`prompt
label: Draft replies to today's emails
instruction: |
For each unanswered email in the digest above, draft a 2-line reply
in my voice and present them as a checklist for me to approve.
\`\`\`
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
## When prose IS appropriate ## When prose IS appropriate
- A **1-3 sentence opening summary** at the top of a complex note (a "lede") concise enough to scan. - A **1-3 sentence opening summary** at the top of a complex note (a "lede") concise enough to scan.

View file

@ -49,9 +49,10 @@ function ensureDefaultConfigs() {
ensureDirs(); ensureDirs();
ensureDefaultConfigs(); ensureDefaultConfigs();
// Ensure default knowledge files exist // One-time deprecation for the old Today.md live dashboard. New installs no
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => { // longer get a generated Today.md; existing files are paused in place.
console.error('[DailyNote] Failed to ensure daily note:', err); import('../knowledge/deprecate_today_note.js').then(m => m.deprecateTodayNote()).catch(err => {
console.error('[TodayNoteDeprecation] Failed to deprecate Today.md:', err);
}); });
// Initialize version history repo (async, fire-and-forget on startup) // Initialize version history repo (async, fire-and-forget on startup)

View file

@ -0,0 +1,250 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { generateObject } from 'ai';
import { google } from 'googleapis';
import type { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { createProvider } from '../models/models.js';
import {
getDefaultModelAndProvider,
getKgModel,
resolveProviderConfig,
} from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import type { GmailThreadSnapshot } from './sync_gmail.js';
const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md');
const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync');
const CALENDAR_LOOKAHEAD_DAYS = 7;
const MAX_CALENDAR_EVENTS = 25;
function readEmailStyleGuide(): string | null {
try {
const raw = fs.readFileSync(STYLE_GUIDE_PATH, 'utf-8').trim();
return raw || null;
} catch {
return null;
}
}
interface CalendarSlice {
summary: string;
startIso: string;
endIso?: string;
}
function readUpcomingCalendar(): CalendarSlice[] {
if (!fs.existsSync(CALENDAR_DIR)) return [];
const now = Date.now();
const cutoff = now + CALENDAR_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
const out: CalendarSlice[] = [];
let names: string[];
try {
names = fs.readdirSync(CALENDAR_DIR);
} catch {
return [];
}
for (const name of names) {
if (!name.endsWith('.json')) continue;
try {
const raw = fs.readFileSync(path.join(CALENDAR_DIR, name), 'utf-8');
const ev = JSON.parse(raw) as {
summary?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
status?: string;
};
if (ev.status === 'cancelled') continue;
const startStr = ev.start?.dateTime ?? ev.start?.date;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (Number.isNaN(startMs)) continue;
if (startMs < now || startMs > cutoff) continue;
out.push({
summary: ev.summary || '(no title)',
startIso: startStr,
endIso: ev.end?.dateTime ?? ev.end?.date,
});
} catch {
// skip malformed
}
}
out.sort((a, b) => Date.parse(a.startIso) - Date.parse(b.startIso));
return out.slice(0, MAX_CALENDAR_EVENTS);
}
function formatCalendar(events: CalendarSlice[]): string {
if (events.length === 0) return '(no upcoming events)';
return events.map((e) => {
const end = e.endIso ? ` ${e.endIso}` : '';
return `- ${e.startIso}${end}: ${e.summary}`;
}).join('\n');
}
let cachedUserEmail: string | null = null;
export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
if (cachedUserEmail) return cachedUserEmail;
try {
const gmailClient = google.gmail({ version: 'v1', auth });
const res = await gmailClient.users.getProfile({ userId: 'me' });
if (res.data.emailAddress) {
cachedUserEmail = res.data.emailAddress.toLowerCase();
return cachedUserEmail;
}
} catch (err) {
console.warn('[Email classifier] getProfile failed:', err);
}
return null;
}
export interface Classification {
importance: 'important' | 'other';
summary?: string;
draftResponse?: string;
}
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
# Importance
Decide if the thread is "important" or "other":
- important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions).
- other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach.
# Summary (important only)
When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied. Omit when "other".
# Draft response (important only)
When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is.
Apply the user's email-style guide (when provided below) match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
- Propose 2-3 specific time windows from genuinely free slots, or
- Confirm/decline a specific time the sender proposed, based on calendar conflicts.
Use the same timezone the user appears to operate in (inferable from their previous messages or calendar events).
Omit the draft when:
- importance is "other"
- the thread is purely informational and doesn't ask for a reply
- the latest message is from the user (they already replied; no draft needed)
- you can't write a meaningful reply without information you don't have (don't fabricate)
Be decisive pick exactly one importance label. Do not hedge.`;
function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
if (!userEmail) return false;
const needle = userEmail.toLowerCase();
return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle));
}
function buildPrompt(
snapshot: GmailThreadSnapshot,
userEmail: string | null,
styleGuide: string | null,
calendar: CalendarSlice[],
): string {
const lines: string[] = [];
if (userEmail) {
lines.push(`# Your identity`);
lines.push(`The user's own email is ${userEmail}. You write as this person when drafting replies.`);
lines.push('');
}
if (styleGuide) {
lines.push(`# Email style guide`);
lines.push(styleGuide);
lines.push('');
}
lines.push(`# User's upcoming calendar (next ${CALENDAR_LOOKAHEAD_DAYS} days)`);
lines.push(formatCalendar(calendar));
lines.push('');
lines.push(`# Thread to classify`);
lines.push(`Subject: ${snapshot.subject || '(no subject)'}`);
lines.push(`Message count: ${snapshot.messages.length}`);
lines.push('');
for (let i = 0; i < snapshot.messages.length; i += 1) {
const msg = snapshot.messages[i];
const isLast = i === snapshot.messages.length - 1;
lines.push(`## Message ${i + 1}${isLast ? ' (latest)' : ''}`);
lines.push(`From: ${msg.from || 'unknown'}`);
if (msg.to) lines.push(`To: ${msg.to}`);
if (msg.date) lines.push(`Date: ${msg.date}`);
const body = (msg.body || '').replace(/\s+/g, ' ').slice(0, isLast ? 2000 : 600).trim();
if (body) {
lines.push('');
lines.push(body);
}
lines.push('');
}
return lines.join('\n');
}
export interface ClassifyOptions {
skipDraft?: boolean;
}
export async function classifyThread(
snapshot: GmailThreadSnapshot,
userEmail: string | null,
options: ClassifyOptions = {},
): Promise<Classification> {
if (userReplied(snapshot, userEmail)) {
return { importance: 'important' };
}
try {
const styleGuide = readEmailStyleGuide();
const calendar = readUpcomingCalendar();
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
const model = createProvider(config).languageModel(modelId);
const systemPrompt = options.skipDraft
? `${SYSTEM_PROMPT}\n\n# Skip the draft\n\nThe user already has their own draft in progress for this thread — DO NOT generate a draftResponse. Always omit the draftResponse field.`
: SYSTEM_PROMPT;
const result = await generateObject({
model,
system: systemPrompt,
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
schema: ClassificationSchema,
});
captureLlmUsage({
useCase: 'knowledge_sync',
subUseCase: 'email_classifier',
model: modelId,
provider,
usage: result.usage,
});
const out: Classification = { importance: result.object.importance };
if (result.object.importance === 'important') {
if (result.object.summary) out.summary = result.object.summary;
if (!options.skipDraft && result.object.draftResponse) out.draftResponse = result.object.draftResponse;
}
return out;
} catch (err) {
console.warn(`[Email classifier] LLM call failed for thread ${snapshot.threadId}:`, err);
return { importance: 'important' };
}
}

View file

@ -0,0 +1,96 @@
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
import { WorkDir } from '../config/config.js';
import { splitFrontmatter, joinFrontmatter } from '../application/lib/parse-frontmatter.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const TODAY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
const STATE_FILE = path.join(WorkDir, 'config', 'today-note-deprecation.json');
const NOTICE_MARKER = '<!-- rowboat-today-md-deprecated -->';
const DEPRECATION_NOTICE = `${NOTICE_MARKER}
> Rowboat's Today.md live dashboard is paused for now while we work on a better experience. You can keep using this note as a regular markdown file. If you want Rowboat to keep updating it automatically, re-enable the live note settings; automatic updates may use credits.
`;
const StateSchema = z.object({
processed_at: z.string().min(1).optional(),
});
type State = z.infer<typeof StateSchema>;
const TodayNoteFrontmatterSchema = z.object({
live: LiveNoteSchema.optional(),
}).passthrough();
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function loadState(): Promise<State> {
try {
if (!await pathExists(STATE_FILE)) return {};
const raw = await fs.readFile(STATE_FILE, 'utf-8');
return StateSchema.parse(JSON.parse(raw));
} catch (error) {
console.warn('[TodayNoteDeprecation] Failed to load state:', error);
return {};
}
}
async function saveState(state: State): Promise<void> {
const dir = path.dirname(STATE_FILE);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
}
async function markProcessed(): Promise<void> {
await saveState({ processed_at: new Date().toISOString() });
}
function disableLiveBlock(frontmatter: Record<string, unknown>): Record<string, unknown> {
const parsed = TodayNoteFrontmatterSchema.safeParse(frontmatter);
if (!parsed.success || !parsed.data.live) {
return frontmatter;
}
return {
...frontmatter,
live: {
...parsed.data.live,
active: false,
},
};
}
function prependNotice(body: string): string {
if (body.includes(NOTICE_MARKER)) return body;
return `${DEPRECATION_NOTICE}${body}`;
}
export async function deprecateTodayNote(): Promise<void> {
const state = await loadState();
if (state.processed_at) return;
if (!await pathExists(TODAY_NOTE_PATH)) {
await markProcessed();
return;
}
const content = await fs.readFile(TODAY_NOTE_PATH, 'utf-8');
const { frontmatter, body } = splitFrontmatter(content);
const nextFrontmatter = disableLiveBlock(frontmatter);
const nextBody = prependNotice(body);
if (nextFrontmatter !== frontmatter || nextBody !== body) {
await fs.writeFile(TODAY_NOTE_PATH, joinFrontmatter(nextFrontmatter, nextBody), 'utf-8');
console.log('[TodayNoteDeprecation] Deprecated Today.md live dashboard');
}
await markProcessed();
}

View file

@ -1,93 +0,0 @@
import path from 'path';
import fs from 'fs';
import { stringify as stringifyYaml } from 'yaml';
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
import { WorkDir } from '../config/config.js';
import { splitFrontmatter } from '../application/lib/parse-frontmatter.js';
import z from 'zod';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
// Bump this whenever the canonical Today.md template changes (objective,
// triggers, default body, etc.). On app start, ensureDailyNote() compares the
// on-disk `templateVersion` against this constant — if older or missing, the
// existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with the
// new template. v2 is the live-note rewrite (single objective, no `track:`).
const CANONICAL_DAILY_NOTE_VERSION = 2;
const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
objective:
`Keep Today.md current as a living dashboard for the day. Maintain these H2 sections in this order:
1. **Overview** 2-3 prose sentences greeting the user and reading the day (warm, confident tone use today's calendar density from \`calendar_sync/\` and the existing Priorities section if populated). Below the prose, render exactly one \`image\` block fitting the mood (use weather + calendar density as cues). Source the image via web-search from a permissive host (Unsplash/Pexels/Pixabay/Wikimedia, direct .jpg/.png/.webp URLs only); fall back to NASA APOD (https://apod.nasa.gov/apod/astropix.html) if nothing suitable. Keep the image **wide / low-height**. Skip refreshing this section if its content is still suitable and less than 24h old.
2. **Calendar** today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\`\`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`.
3. **Emails** a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
4. **What you missed** a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
5. **Priorities** a ranked markdown list of actionable items the user should focus on today. Sources: yesterday's meeting action items (\`knowledge/Meetings/<source>/<yesterday>/\`), open follow-ups across \`knowledge/\` (\`workspace-grep\` for "- [ ]"), the **What you missed** section above. Don't list calendar events as tasks (Calendar section has them) and don't list trivial admin. Rank by importance; note time-sensitivity inline. With an event payload (gmail or calendar), only re-emit the full list if the event genuinely shifts priorities (urgent reply, deadline arrival, blocking reschedule). If nothing pressing: "No pressing tasks today — good day to make progress on bigger items."
Treat the note as a coherent artifact. Make small, incremental edits one section at a time rather than rewriting the whole body each run.`,
active: true,
triggers: {
// Three windows give the user a fresh dashboard morning, midday, and
// post-lunch even with no calendar/email events landing in between.
windows: [
{ startTime: '08:00', endTime: '12:00' },
{ startTime: '12:00', endTime: '15:00' },
{ startTime: '15:00', endTime: '18:00' },
],
// Event-driven updates handle in-day shifts (new email threads worth
// attention, calendar reshuffles, urgent escalations).
eventMatchCriteria:
`Email or calendar events that may shift today's dashboard: new or updated email threads needing the user's attention, urgent reply requests, deadline-bearing items, escalations from people the user cares about, calendar additions/cancellations/reschedules affecting today, or anything that changes the user's near-term priorities. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
},
};
function buildDailyNoteContent(body: string = '# Today\n'): string {
const fm = stringifyYaml(
{ templateVersion: CANONICAL_DAILY_NOTE_VERSION, live: TODAY_LIVE_NOTE },
{ lineWidth: 0, blockQuote: 'literal' },
).trimEnd();
return `---\n${fm}\n---\n${body}`;
}
function readCurrentTemplateVersion(): number {
if (!fs.existsSync(DAILY_NOTE_PATH)) return -1;
const raw = fs.readFileSync(DAILY_NOTE_PATH, 'utf-8');
const { frontmatter } = splitFrontmatter(raw);
const v = frontmatter.templateVersion;
return typeof v === 'number' ? v : 0;
}
export function ensureDailyNote(): void {
// Fresh install — no existing file.
if (!fs.existsSync(DAILY_NOTE_PATH)) {
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
console.log(`[DailyNote] Created Today.md (v${CANONICAL_DAILY_NOTE_VERSION})`);
return;
}
// Up-to-date — nothing to do.
const currentVersion = readCurrentTemplateVersion();
if (currentVersion >= CANONICAL_DAILY_NOTE_VERSION) return;
// Migrate aggressively: rename existing → backup, write a fresh canonical
// template (no body carried over). Today.md is a flagship demo whose
// content is meant to be regenerated by the live-note agent anyway —
// preserving the old body just leaves orphan sections behind on
// restructure. The .bkp file is the recovery path; its name doesn't end
// in `.md`, so the scheduler and event router naturally skip it. Pre-v2
// notes (with the old `track:` array) are caught by this same path.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${DAILY_NOTE_PATH}.bkp.${stamp}`;
fs.renameSync(DAILY_NOTE_PATH, backupPath);
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
console.log(
`[DailyNote] Migrated v${currentVersion} → v${CANONICAL_DAILY_NOTE_VERSION}; ` +
`previous version saved to ${backupPath}`,
);
}

View file

@ -82,165 +82,6 @@ ${KNOWLEDGE_NOTE_STYLE_GUIDE}
The style guide above is the canonical writing style for everything you emit into the body. The objective may specify a particular shape ("3-column markdown table: Location | Local Time | Offset") when it does, follow it exactly. When it doesn't, walk the ladder above and pick the tightest shape that fits the data. The style guide above is the canonical writing style for everything you emit into the body. The objective may specify a particular shape ("3-column markdown table: Location | Local Time | Offset") when it does, follow it exactly. When it doesn't, walk the ladder above and pick the tightest shape that fits the data.
# Output Block Types
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the objective asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
Do **not** emit \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
## \`table\` — tabular data (JSON)
Use for: scoreboards, leaderboards, comparisons, multi-row status digests.
\`\`\`table
{
"title": "Top stories on Hacker News",
"columns": ["Rank", "Title", "Points", "Comments"],
"data": [
{"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312},
{"Rank": 2, "Title": "...", "Points": 530, "Comments": 144}
]
}
\`\`\`
Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`.
## \`chart\` — line / bar / pie chart (JSON)
Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning.
\`\`\`chart
{
"chart": "line",
"title": "USD/INR — last 7 days",
"x": "date",
"y": "rate",
"data": [
{"date": "2026-04-13", "rate": 83.41},
{"date": "2026-04-14", "rate": 83.38}
]
}
\`\`\`
Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`.
## \`mermaid\` — diagrams (raw Mermaid source)
Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps.
\`\`\`mermaid
graph LR
A[Project Alpha] --> B[Sarah Chen]
A --> C[Acme Corp]
B --> D[Q3 Launch]
\`\`\`
Body is plain Mermaid source no JSON wrapper.
## \`calendar\` — list of events (JSON)
Use for: upcoming meetings, agenda digests, day/week views.
\`\`\`calendar
{
"title": "Today",
"events": [
{
"summary": "1:1 with Sarah",
"start": {"dateTime": "2026-04-20T10:00:00-07:00"},
"end": {"dateTime": "2026-04-20T10:30:00-07:00"},
"location": "Zoom",
"conferenceLink": "https://zoom.us/j/..."
}
]
}
\`\`\`
Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool).
## \`email\` — single email or thread digest (JSON)
Use for: surfacing one important thread latest message body, summary of prior context, optional draft reply.
\`\`\`email
{
"subject": "Q3 launch readiness",
"from": "sarah@acme.com",
"date": "2026-04-19T16:42:00Z",
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
}
\`\`\`
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
## \`image\` — single image (JSON)
Use for: charts, screenshots, photos you have a URL or workspace path for.
\`\`\`image
{
"src": "https://example.com/forecast.png",
"alt": "Weather forecast",
"caption": "Bay Area · April 20"
}
\`\`\`
Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`.
## \`embed\` — YouTube / Figma / Tweet embed (JSON)
Use for: linking to a video, design, or tweet that should render inline.
\`\`\`embed
{
"provider": "youtube",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"caption": "Latest demo"
}
\`\`\`
Required: \`provider\` ("youtube" | "figma" | "tweet" | "generic"), \`url\`. Optional: \`caption\`.
## \`iframe\` — arbitrary embedded webpage (JSON)
Use for: live dashboards, status pages, trackers anything that has its own webpage and benefits from being live, not snapshotted.
\`\`\`iframe
{
"url": "https://status.example.com",
"title": "Service status",
"height": 600
}
\`\`\`
Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (2401600), \`allow\` (Permissions-Policy string).
## \`transcript\` — long transcript (JSON)
Use for: meeting transcripts, voice-note dumps bodies that benefit from a collapsible UI.
\`\`\`transcript
{"transcript": "[00:00] Speaker A: Welcome everyone..."}
\`\`\`
Required: \`transcript\` (string).
## \`prompt\` — starter Copilot prompt (YAML)
Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot, with this note attached as a file mention.
\`\`\`prompt
label: Draft replies to today's emails
instruction: |
For each unanswered email in the digest above, draft a 2-line reply
in my voice and present them as a checklist for me to approve.
\`\`\`
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
# Interpreting the Objective # Interpreting the Objective
The objective was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect: The objective was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:

View file

@ -134,7 +134,7 @@ async function publishCalendarSyncEvent(
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const LOOKBACK_DAYS = 7; const LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [ const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly',

View file

@ -8,19 +8,114 @@ import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { createEvent } from '../events/producer.js'; import { createEvent } from '../events/producer.js';
import { classifyThread, getUserEmail } from './classify_thread.js';
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LEGACY_CACHE_DIR = path.join(SYNC_DIR, 'cache');
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
(function migrateLegacyCacheDir() {
try {
if (fs.existsSync(LEGACY_CACHE_DIR) && !fs.existsSync(CACHE_DIR)) {
fs.renameSync(LEGACY_CACHE_DIR, CACHE_DIR);
console.log(`[Gmail] Migrated cache from ${LEGACY_CACHE_DIR}${CACHE_DIR}`);
}
} catch (err) {
console.warn('[Gmail] Cache directory migration failed:', err);
}
})();
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const MAX_THREADS_IN_DIGEST = 10; const MAX_THREADS_IN_DIGEST = 10;
const nhm = new NodeHtmlMarkdown(); const nhm = new NodeHtmlMarkdown();
interface SnapshotCacheEntry {
historyId: string;
fetchedAt: string;
snapshot: GmailThreadSnapshot;
}
function cachePath(threadId: string): string {
return path.join(CACHE_DIR, `${encodeURIComponent(threadId)}.json`);
}
function readCachedSnapshot(threadId: string): SnapshotCacheEntry | null {
try {
const raw = fs.readFileSync(cachePath(threadId), 'utf-8');
return JSON.parse(raw) as SnapshotCacheEntry;
} catch {
return null;
}
}
function writeCachedSnapshot(threadId: string, historyId: string, snapshot: GmailThreadSnapshot): void {
try {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
const entry: SnapshotCacheEntry = {
historyId,
fetchedAt: new Date().toISOString(),
snapshot,
};
fs.writeFileSync(cachePath(threadId), JSON.stringify(entry), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] write failed for ${threadId}:`, err);
}
}
export function saveMessageBodyHeight(threadId: string, messageId: string, height: number): void {
const cached = readCachedSnapshot(threadId);
if (!cached) return;
const message = cached.snapshot.messages.find((m) => m.id === messageId);
if (!message) return;
if (message.bodyHeight === height) return;
message.bodyHeight = height;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] height write failed for ${threadId}/${messageId}:`, err);
}
}
interface SyncedThread { interface SyncedThread {
threadId: string; threadId: string;
markdown: string; markdown: string;
} }
export interface GmailThreadSnapshot {
threadId: string;
threadUrl: string;
summary?: string;
subject?: string;
from?: string;
to?: string;
date?: string;
latest_email?: string;
past_summary?: string;
unread?: boolean;
importance?: 'important' | 'other';
draft_response?: string;
gmail_draft?: string;
messages: Array<{
id?: string;
from?: string;
to?: string;
cc?: string;
date?: string;
subject?: string;
body?: string;
bodyHtml?: string;
unread?: boolean;
bodyHeight?: number;
attachments?: Array<{
filename: string;
mimeType?: string;
sizeBytes?: number;
savedPath: string;
}>;
}>;
}
function summarizeGmailSync(threads: SyncedThread[]): string { function summarizeGmailSync(threads: SyncedThread[]): string {
const lines: string[] = [ const lines: string[] = [
`# Gmail sync update`, `# Gmail sync update`,
@ -93,35 +188,416 @@ function decodeBase64(data: string): string {
return Buffer.from(data, 'base64').toString('utf-8'); return Buffer.from(data, 'base64').toString('utf-8');
} }
function extractBodyParts(payload: gmail.Schema$MessagePart): { text: string; html: string } {
const out = { text: '', html: '' };
const walk = (part: gmail.Schema$MessagePart): void => {
const mime = part.mimeType || '';
if (mime === 'text/html' && part.body?.data) {
if (!out.html) out.html = decodeBase64(part.body.data);
return;
}
if (mime === 'text/plain' && part.body?.data) {
if (!out.text) out.text = decodeBase64(part.body.data);
return;
}
if (part.parts) {
for (const sub of part.parts) walk(sub);
}
};
walk(payload);
return out;
}
function getBody(payload: gmail.Schema$MessagePart): string { function getBody(payload: gmail.Schema$MessagePart): string {
let body = ""; const { text, html } = extractBodyParts(payload);
if (payload.parts) { if (html) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body && part.body.data) {
const text = decodeBase64(part.body.data);
// Strip quoted lines
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>'));
body += cleanLines.join('\n');
} else if (part.mimeType === 'text/html' && part.body && part.body.data) {
const html = decodeBase64(part.body.data);
const md = nhm.translate(html); const md = nhm.translate(html);
// Simple quote stripping for MD return md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>')); }
body += cleanLines.join('\n'); if (text) {
} else if (part.parts) { return text.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
body += getBody(part); }
return '';
}
interface ExtractedAttachment {
filename: string;
mimeType?: string;
sizeBytes?: number;
savedPath: string;
}
/**
* Walk a message MIME tree and collect "real" attachments parts with a
* filename + attachmentId, excluding cid-referenced inline images (those
* already get baked into bodyHtml as data URLs).
*
* Returns workspace-relative paths matching the convention used by
* saveAttachment / processThread, so the renderer can hand them to
* shell.openPath via the existing IPC.
*/
function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart): ExtractedAttachment[] {
const out: ExtractedAttachment[] = [];
const walk = (part: gmail.Schema$MessagePart): void => {
const filename = part.filename;
const attId = part.body?.attachmentId;
if (filename && attId) {
// Exclude only true inline images (image/* with a Content-ID, which
// get baked into bodyHtml as data URLs by inlineCidImages). Other
// parts with Content-ID — PDFs, .log files, .ics, etc. — are real
// attachments; Gmail just stamps Content-ID on most parts.
const cid = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const mime = part.mimeType || '';
const isInlineImage = !!cid && mime.startsWith('image/');
if (!isInlineImage) {
const safeName = `${msgId}_${cleanFilename(filename)}`;
out.push({
filename,
mimeType: part.mimeType ?? undefined,
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
savedPath: `gmail_sync/attachments/${safeName}`,
});
} }
} }
} else if (payload.body && payload.body.data) { if (part.parts) for (const sub of part.parts) walk(sub);
const data = decodeBase64(payload.body.data); };
if (payload.mimeType === 'text/html') { walk(payload);
const md = nhm.translate(data); return out;
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); }
} else {
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); async function inlineCidImages(
gmailClient: gmail.Gmail,
messageId: string,
payload: gmail.Schema$MessagePart,
html: string,
): Promise<string> {
if (!/src\s*=\s*["']?cid:/i.test(html)) return html;
const inlineParts: Array<{ contentId: string; mimeType: string; attachmentId: string }> = [];
const collect = (part: gmail.Schema$MessagePart): void => {
const cidHeader = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const attachmentId = part.body?.attachmentId;
const mime = part.mimeType || '';
if (cidHeader && attachmentId && mime.startsWith('image/')) {
inlineParts.push({
contentId: cidHeader.replace(/^<|>$/g, '').trim(),
mimeType: mime,
attachmentId,
});
}
if (part.parts) for (const sub of part.parts) collect(sub);
};
collect(payload);
if (inlineParts.length === 0) return html;
const dataUrls = new Map<string, string>();
await Promise.all(inlineParts.map(async (part) => {
try {
const res = await gmailClient.users.messages.attachments.get({
userId: 'me',
messageId,
id: part.attachmentId,
});
const b64 = res.data.data;
if (!b64) return;
// Gmail returns base64url; data URLs need standard base64
const normalized = b64.replace(/-/g, '+').replace(/_/g, '/');
dataUrls.set(part.contentId, `data:${part.mimeType};base64,${normalized}`);
} catch (err) {
console.warn(`[Gmail] inline image fetch failed for ${part.contentId}:`, err);
}
}));
let rewritten = html;
for (const [cid, url] of dataUrls) {
const escaped = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
rewritten = rewritten.replace(new RegExp(`cid:${escaped}`, 'gi'), url);
}
return rewritten;
}
function normalizeBody(body: string): string {
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
}
export interface RecentThreadInfo {
threadId: string;
historyId: string;
snippet?: string;
}
export type InboxSection = 'important' | 'other';
export interface InboxPageOptions {
section: InboxSection;
cursor?: string;
limit?: number;
}
export interface InboxPageResult {
threads: GmailThreadSnapshot[];
nextCursor: string | null;
}
interface IndexedEntry {
threadId: string;
dateMs: number;
snapshot: GmailThreadSnapshot;
}
function snapshotImportance(s: GmailThreadSnapshot): InboxSection {
return s.importance === 'other' ? 'other' : 'important';
}
function snapshotDateMs(s: GmailThreadSnapshot): number {
const latest = s.messages[s.messages.length - 1];
const raw = latest?.date || s.date;
if (!raw) return 0;
const ms = Date.parse(raw);
return Number.isFinite(ms) ? ms : 0;
}
function parseCursor(cursor: string | undefined): { dateMs: number; threadId: string } | null {
if (!cursor) return null;
const idx = cursor.indexOf('|');
if (idx < 0) return null;
const dateMs = Number(cursor.slice(0, idx));
const threadId = cursor.slice(idx + 1);
if (!Number.isFinite(dateMs) || !threadId) return null;
return { dateMs, threadId };
}
function encodeCursor(entry: { dateMs: number; threadId: string }): string {
return `${entry.dateMs}|${entry.threadId}`;
}
export function listImportantThreads(opts: { cursor?: string; limit?: number } = {}): InboxPageResult {
return listInboxPage({ section: 'important', ...opts });
}
export function listEverythingElseThreads(opts: { cursor?: string; limit?: number } = {}): InboxPageResult {
return listInboxPage({ section: 'other', ...opts });
}
export function listInboxPage(opts: InboxPageOptions): InboxPageResult {
const limit = Math.max(1, Math.min(100, opts.limit ?? 25));
const cursor = parseCursor(opts.cursor);
if (!fs.existsSync(CACHE_DIR)) return { threads: [], nextCursor: null };
let names: string[];
try {
names = fs.readdirSync(CACHE_DIR);
} catch {
return { threads: [], nextCursor: null };
}
const entries: IndexedEntry[] = [];
for (const name of names) {
if (!name.endsWith('.json')) continue;
const filePath = path.join(CACHE_DIR, name);
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const wrapper = JSON.parse(raw) as SnapshotCacheEntry;
const snapshot = wrapper.snapshot;
if (!snapshot) continue;
if (snapshotImportance(snapshot) !== opts.section) continue;
entries.push({
threadId: snapshot.threadId,
dateMs: snapshotDateMs(snapshot),
snapshot,
});
} catch (err) {
console.warn(`[Inbox lists] read failed for ${name}:`, err);
} }
} }
return body;
// Newest first, threadId asc as tiebreak.
entries.sort((a, b) => {
if (b.dateMs !== a.dateMs) return b.dateMs - a.dateMs;
return a.threadId < b.threadId ? -1 : 1;
});
let startIdx = 0;
if (cursor) {
startIdx = entries.findIndex((e) => {
if (e.dateMs < cursor.dateMs) return true;
if (e.dateMs === cursor.dateMs && e.threadId > cursor.threadId) return true;
return false;
});
if (startIdx < 0) startIdx = entries.length;
}
const slice = entries.slice(startIdx, startIdx + limit);
const hasMore = startIdx + slice.length < entries.length;
const last = slice[slice.length - 1];
return {
threads: slice.map((e) => e.snapshot),
nextCursor: hasMore && last ? encodeCursor({ dateMs: last.dateMs, threadId: last.threadId }) : null,
};
}
export async function listRecentThreadIds(daysAgo: number = 2): Promise<RecentThreadInfo[]> {
const auth = await GoogleClientFactory.getClient();
if (!auth) {
throw new Error('Gmail is not connected.');
}
const gmailClient = google.gmail({ version: 'v1', auth });
const since = new Date();
since.setDate(since.getDate() - daysAgo);
const dateQuery = since.toISOString().split('T')[0].replace(/-/g, '/');
const results: RecentThreadInfo[] = [];
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
q: `after:${dateQuery}`,
pageToken,
});
const threads = res.data.threads || [];
for (const thread of threads) {
if (thread.id && thread.historyId) {
results.push({
threadId: thread.id,
historyId: thread.historyId,
snippet: thread.snippet || undefined,
});
}
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return results;
}
/**
* Build a GmailThreadSnapshot from an already-fetched threads.get response,
* classify it, and write to inbox_lists/. Called by the background sync
* (processThread) the only path that materializes snapshots.
*
* Returns null when the thread has no visible (non-draft) messages
* those shouldn't show up in the inbox.
*/
async function buildAndCacheSnapshot(
threadId: string,
threadData: gmail.Schema$Thread,
gmailClient: gmail.Gmail,
auth: OAuth2Client,
): Promise<GmailThreadSnapshot | null> {
const messages = threadData.messages;
if (!messages || messages.length === 0) return null;
const cached = readCachedSnapshot(threadId);
// Short-circuit: if the thread hasn't changed since we last classified it,
// skip the rebuild + classifier. Saves the cid-image fetches and one LLM
// call per unchanged thread (matters most during fullSync after a
// historyId expiry, where the whole window is re-walked).
// We require `importance` to be present too — pre-classifier cache files
// would otherwise stick around forever uncategorised.
if (
threadData.historyId &&
cached &&
cached.historyId === threadData.historyId &&
cached.snapshot.importance
) {
return cached.snapshot;
}
const heightCarryover = new Map<string, number>();
if (cached) {
for (const m of cached.snapshot.messages) {
if (m.id && typeof m.bodyHeight === 'number') heightCarryover.set(m.id, m.bodyHeight);
}
}
const parsed = await Promise.all(messages.map(async (msg) => {
const headers = msg.payload?.headers || [];
const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' };
const body = msg.payload ? normalizeBody(getBody(msg.payload)) : '';
let bodyHtml: string | undefined;
if (parts.html && msg.payload && msg.id) {
try {
bodyHtml = await inlineCidImages(gmailClient, msg.id, msg.payload, parts.html);
} catch (err) {
console.warn(`[Gmail] inline image embed failed for message ${msg.id}:`, err);
bodyHtml = parts.html;
}
}
const isDraft = msg.labelIds?.includes('DRAFT') ?? false;
const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload) : [];
return {
id: msg.id || undefined,
from: headerValue(headers, 'From') || 'Unknown',
to: headerValue(headers, 'To'),
cc: headerValue(headers, 'Cc'),
date: headerValue(headers, 'Date'),
subject: headerValue(headers, 'Subject') || '(No Subject)',
body,
bodyHtml,
unread: msg.labelIds?.includes('UNREAD') ?? false,
bodyHeight: msg.id ? heightCarryover.get(msg.id) : undefined,
messageIdHeader: headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined,
attachments: attachments.length > 0 ? attachments : undefined,
isDraft,
};
}));
const sentMessages = parsed.filter((m) => !m.isDraft);
const draftMessages = parsed.filter((m) => m.isDraft);
const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest);
const latestDraftBody = draftMessages.length > 0
? draftMessages[draftMessages.length - 1]!.body.trim()
: '';
if (visibleMessages.length === 0) return null;
const latest = visibleMessages[visibleMessages.length - 1]!;
const earlier = visibleMessages.slice(0, -1);
const earlierSummary = earlier
.map((msg) => {
const date = msg.date ? ` (${msg.date})` : '';
const body = msg.body.replace(/\s+/g, ' ').slice(0, 500).trim();
return `${msg.from}${date}: ${body}`;
})
.filter(Boolean)
.join('\n\n');
const snapshot: GmailThreadSnapshot = {
threadId,
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
subject: latest.subject || visibleMessages[0]?.subject,
from: latest.from,
to: latest.to,
date: latest.date,
latest_email: latest.body,
past_summary: earlierSummary || undefined,
unread: visibleMessages.some((m) => m.unread),
messages: visibleMessages,
gmail_draft: latestDraftBody || undefined,
};
try {
const userEmail = await getUserEmail(auth);
const skipDraft = latestDraftBody.length > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) snapshot.draft_response = classification.draftResponse;
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
} }
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> { async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
@ -225,6 +701,14 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`); console.log(`Synced Thread: ${subject} (${threadId})`);
// Also build + cache the rich snapshot for the inbox view.
// Reuses the threads.get response — no extra API call.
try {
await buildAndCacheSnapshot(threadId, thread, gmail, auth);
} catch (err) {
console.warn(`[Gmail] Inbox snapshot build failed for ${threadId}:`, err);
}
return { threadId, markdown: mdContent }; return { threadId, markdown: mdContent };
} catch (error) { } catch (error) {
@ -233,6 +717,46 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
} }
} }
/**
* After a sync cycle, prune inbox_lists/ entries for threadIds that are
* no longer in INBOX (archived/trashed elsewhere). Single threads.list call,
* keeps the cache in lock-step with Gmail's INBOX label.
*/
async function pruneInboxCache(auth: OAuth2Client): Promise<void> {
if (!fs.existsSync(CACHE_DIR)) return;
try {
const gmailClient = google.gmail({ version: 'v1', auth });
const inInbox = new Set<string>();
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
labelIds: ['INBOX'],
maxResults: 500,
pageToken,
});
for (const t of res.data.threads || []) {
if (t.id) inInbox.add(t.id);
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
for (const name of fs.readdirSync(CACHE_DIR)) {
if (!name.endsWith('.json')) continue;
const threadId = decodeURIComponent(name.replace(/\.json$/, ''));
if (!inInbox.has(threadId)) {
try {
fs.rmSync(path.join(CACHE_DIR, name), { force: true });
} catch (err) {
console.warn(`[Gmail] prune failed for ${threadId}:`, err);
}
}
}
} catch (err) {
console.warn('[Gmail] pruneInboxCache failed:', err);
}
}
function loadState(stateFile: string): { historyId?: string; last_sync?: string } { function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
if (fs.existsSync(stateFile)) { if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
@ -507,14 +1031,28 @@ async function performSync() {
console.log("Authorization successful. Starting sync..."); console.log("Authorization successful. Starting sync...");
const state = loadState(STATE_FILE); const state = loadState(STATE_FILE);
// Backfill case: users who upgraded from a pre-inbox-view build have a
// stored historyId but no inbox_lists/ cache, so partialSync would only
// touch *new* threads and the inbox UI would stay empty. Force a one-
// shot fullSync to populate snapshots for the lookback window. After
// this runs once, the cache directory is populated and we fall back to
// partial-sync on subsequent calls.
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
if (!state.historyId) { if (!state.historyId) {
console.log("No history ID found, starting full sync..."); console.log("No history ID found, starting full sync...");
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else if (cacheMissing) {
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else { } else {
console.log("History ID found, starting partial sync..."); console.log("History ID found, starting partial sync...");
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} }
// Keep inbox_lists/ in lock-step with Gmail's INBOX label —
// remove cache files for threads that were archived/trashed elsewhere.
await pruneInboxCache(auth);
console.log("Sync completed."); console.log("Sync completed.");
} catch (error) { } catch (error) {
console.error("Error during sync:", error); console.error("Error during sync:", error);

View file

@ -7,7 +7,7 @@ import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview"; const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite-preview";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "anthropic/claude-haiku-4.5"; const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite-preview";
/** /**
* The single source of truth for "what model+provider should we use when * The single source of truth for "what model+provider should we use when

View file

@ -88,12 +88,13 @@ export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
export const EmailBlockSchema = z.object({ export const EmailBlockSchema = z.object({
threadId: z.string().optional(), threadId: z.string().optional(),
threadUrl: z.string().url().optional(),
summary: z.string().optional(), summary: z.string().optional(),
subject: z.string().optional(), subject: z.string().optional(),
from: z.string().optional(), from: z.string().optional(),
to: z.string().optional(), to: z.string().optional(),
date: z.string().optional(), date: z.string().optional(),
latest_email: z.string(), latest_email: z.string().optional(),
past_summary: z.string().optional(), past_summary: z.string().optional(),
draft_response: z.string().optional(), draft_response: z.string().optional(),
response_mode: z.enum(['inline', 'assistant', 'both']).optional(), response_mode: z.enum(['inline', 'assistant', 'both']).optional(),
@ -101,6 +102,42 @@ export const EmailBlockSchema = z.object({
export type EmailBlock = z.infer<typeof EmailBlockSchema>; export type EmailBlock = z.infer<typeof EmailBlockSchema>;
export const GmailAttachmentSchema = z.object({
filename: z.string(),
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative().optional(),
savedPath: z.string(),
});
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
export const GmailThreadMessageSchema = z.object({
id: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
cc: z.string().optional(),
date: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
bodyHtml: z.string().optional(),
unread: z.boolean().optional(),
bodyHeight: z.number().int().positive().optional(),
attachments: z.array(GmailAttachmentSchema).optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
export const GmailThreadSchema = EmailBlockSchema.extend({
threadId: z.string(),
threadUrl: z.string().url(),
unread: z.boolean().optional(),
importance: z.enum(['important', 'other']).optional(),
gmail_draft: z.string().optional(),
messages: z.array(GmailThreadMessageSchema),
});
export type GmailThread = z.infer<typeof GmailThreadSchema>;
export const EmailsBlockSchema = z.object({ export const EmailsBlockSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
emails: z.array(EmailBlockSchema), emails: z.array(EmailBlockSchema),

View file

@ -18,6 +18,7 @@ import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js'; import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js'; import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js'; import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -123,6 +124,38 @@ const ipcSchemas = {
req: WorkspaceChangeEvent, req: WorkspaceChangeEvent,
res: z.null(), res: z.null(),
}, },
'gmail:getImportant': {
req: z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
nextCursor: z.string().nullable(),
}),
},
'gmail:getEverythingElse': {
req: z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
nextCursor: z.string().nullable(),
}),
},
'gmail:triggerSync': {
req: z.object({}),
res: z.object({}),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),
messageId: z.string().min(1),
height: z.number().int().positive(),
}),
res: z.object({}),
},
'mcp:listTools': { 'mcp:listTools': {
req: z.object({ req: z.object({
serverName: z.string(), serverName: z.string(),