mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +02:00
commit
aba65843c2
26 changed files with 4682 additions and 415 deletions
|
|
@ -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).
|
||||
|
||||
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`)
|
||||
|
||||
|
|
@ -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 at-or-above canonical → no-op.
|
||||
- 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).
|
||||
|
||||
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.
|
||||
- File missing → mark processed and do nothing.
|
||||
- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body.
|
||||
- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -393,7 +391,7 @@ Conventions:
|
|||
| 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 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` |
|
||||
| Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` |
|
||||
| Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` |
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -482,6 +483,20 @@ export function setupIpcHandlers() {
|
|||
'workspace:remove': async (_event, args) => {
|
||||
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) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
|
|||
41
apps/x/apps/renderer/DESIGN_LANGUAGE.md
Normal file
41
apps/x/apps/renderer/DESIGN_LANGUAGE.md
Normal 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
|
|
@ -24,6 +24,8 @@ import { SidebarContentPanel } from '@/components/sidebar-content';
|
|||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||
import { LiveNotesView } from '@/components/live-notes-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 {
|
||||
Conversation,
|
||||
|
|
@ -176,8 +178,10 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
|
|||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
||||
const MEETINGS_TAB_PATH = '__rowboat_meetings__'
|
||||
const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__'
|
||||
const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__'
|
||||
const EMAIL_TAB_PATH = '__rowboat_email__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
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 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 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 getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
|
|
@ -556,7 +562,9 @@ type ViewState =
|
|||
| { type: 'graph' }
|
||||
| { type: 'task'; name: string }
|
||||
| { type: 'suggested-topics' }
|
||||
| { type: 'meetings' }
|
||||
| { type: 'live-notes' }
|
||||
| { type: 'email' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
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
|
||||
* 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
|
||||
* chat: ?type=chat&runId=abc123 (runId optional)
|
||||
* graph: ?type=graph
|
||||
* task: ?type=task&name=daily-brief
|
||||
* suggested-topics: ?type=suggested-topics
|
||||
* meetings: ?type=meetings
|
||||
* live-notes: ?type=live-notes
|
||||
*/
|
||||
function parseDeepLink(input: string): ViewState | null {
|
||||
|
|
@ -601,6 +610,8 @@ function parseDeepLink(input: string): ViewState | null {
|
|||
}
|
||||
case 'suggested-topics':
|
||||
return { type: 'suggested-topics' }
|
||||
case 'meetings':
|
||||
return { type: 'meetings' }
|
||||
case 'live-notes':
|
||||
return { type: 'live-notes' }
|
||||
default:
|
||||
|
|
@ -652,7 +663,7 @@ function ContentHeader({
|
|||
const isCollapsed = state === "collapsed"
|
||||
return (
|
||||
<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={{
|
||||
paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12,
|
||||
paddingRight: 12,
|
||||
|
|
@ -708,13 +719,18 @@ function App() {
|
|||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
|
||||
const [isMeetingsOpen, setIsMeetingsOpen] = useState(false)
|
||||
const [isLiveNotesOpen, setIsLiveNotesOpen] = useState(false)
|
||||
const [isBgTasksOpen, setIsBgTasksOpen] = useState(false)
|
||||
const [isEmailOpen, setIsEmailOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{
|
||||
path: string | null
|
||||
graph: boolean
|
||||
suggestedTopics: boolean
|
||||
meetings: boolean
|
||||
liveNotes: boolean
|
||||
bgTasks: boolean
|
||||
email: boolean
|
||||
} | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
|
|
@ -1039,8 +1055,10 @@ function App() {
|
|||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
||||
if (isMeetingsTabPath(tab.path)) return 'Meetings'
|
||||
if (isLiveNotesTabPath(tab.path)) return 'Live notes'
|
||||
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.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -2753,7 +2771,7 @@ function App() {
|
|||
setActiveFileTabId(existingTab.id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedPath(path)
|
||||
return
|
||||
}
|
||||
|
|
@ -2762,7 +2780,7 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedPath(path)
|
||||
}, [fileTabs, dismissBrowserOverlay])
|
||||
|
||||
|
|
@ -2781,32 +2799,62 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
return
|
||||
}
|
||||
if (isLiveNotesTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
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)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
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)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
|
|
@ -2829,7 +2877,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2843,21 +2891,48 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
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)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
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 {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -2888,12 +2963,15 @@ function App() {
|
|||
dismissBrowserOverlay()
|
||||
handleNewChat()
|
||||
// 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({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
suggestedTopics: isSuggestedTopicsOpen,
|
||||
meetings: isMeetingsOpen,
|
||||
liveNotes: isLiveNotesOpen,
|
||||
bgTasks: isBgTasksOpen,
|
||||
email: isEmailOpen,
|
||||
})
|
||||
} else {
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -2902,8 +2980,8 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -3035,12 +3113,15 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// 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({
|
||||
path: selectedPath,
|
||||
graph: isGraphOpen,
|
||||
suggestedTopics: isSuggestedTopicsOpen,
|
||||
meetings: isMeetingsOpen,
|
||||
liveNotes: isLiveNotesOpen,
|
||||
bgTasks: isBgTasksOpen,
|
||||
email: isEmailOpen,
|
||||
})
|
||||
}
|
||||
dismissBrowserOverlay()
|
||||
|
|
@ -3048,27 +3129,51 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay])
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
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) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
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) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -3078,12 +3183,14 @@ function App() {
|
|||
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
if (isEmailOpen) return { type: 'email' }
|
||||
if (isMeetingsOpen) return { type: 'meetings' }
|
||||
if (isLiveNotesOpen) return { type: 'live-notes' }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
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 last = stack[stack.length - 1]
|
||||
|
|
@ -3151,6 +3258,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [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 existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path))
|
||||
if (existing) {
|
||||
|
|
@ -3162,12 +3280,38 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [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(() => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
|
|
@ -3175,6 +3319,21 @@ function App() {
|
|||
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) => {
|
||||
switch (view.type) {
|
||||
case 'file':
|
||||
|
|
@ -3184,7 +3343,7 @@ function App() {
|
|||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -3199,7 +3358,7 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -3212,7 +3371,7 @@ function App() {
|
|||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
|
|
@ -3225,9 +3384,23 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
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':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3236,9 +3409,26 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false)
|
||||
setIsBgTasksOpen(false)
|
||||
setIsEmailOpen(false)
|
||||
setIsLiveNotesOpen(true)
|
||||
ensureLiveNotesFileTab()
|
||||
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':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3247,7 +3437,7 @@ function App() {
|
|||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsLiveNotesOpen(false); setIsBgTasksOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -3255,7 +3445,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -3577,7 +3767,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// 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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3650,19 +3840,23 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
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
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: '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
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
? SUGGESTED_TOPICS_TAB_PATH
|
||||
: isMeetingsOpen
|
||||
? MEETINGS_TAB_PATH
|
||||
: isLiveNotesOpen
|
||||
? LIVE_NOTES_TAB_PATH
|
||||
: isBgTasksOpen
|
||||
? BG_TASKS_TAB_PATH
|
||||
: isEmailOpen
|
||||
? EMAIL_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
|
|
@ -3717,7 +3911,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('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') => {
|
||||
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)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -3868,14 +4062,14 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// 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)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -4471,7 +4665,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: 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 shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4488,11 +4682,11 @@ function App() {
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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 })
|
||||
}
|
||||
}}>
|
||||
<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 */}
|
||||
<SidebarProvider
|
||||
style={{
|
||||
|
|
@ -4521,7 +4715,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4532,7 +4726,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// 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))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4556,14 +4750,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4583,20 +4777,19 @@ function App() {
|
|||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isSearchOpen={isSearchOpen}
|
||||
isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
isMeetingsOpen={isMeetingsOpen}
|
||||
onOpenMeetings={openMeetingsView}
|
||||
isLiveNotesOpen={isLiveNotesOpen}
|
||||
onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })}
|
||||
isBgTasksOpen={isBgTasksOpen}
|
||||
onOpenBgTasks={openBgTasksView}
|
||||
isEmailOpen={isEmailOpen}
|
||||
onOpenEmail={openEmailView}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4616,7 +4809,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4624,7 +4817,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
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
|
||||
|
|
@ -4677,7 +4870,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4692,7 +4885,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4707,7 +4900,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4740,6 +4933,15 @@ function App() {
|
|||
}}
|
||||
/>
|
||||
</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 ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<LiveNotesView
|
||||
|
|
@ -4760,6 +4962,10 @@ function App() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isEmailOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<EmailView />
|
||||
</div>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
@ -5057,7 +5263,7 @@ function App() {
|
|||
})}
|
||||
</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="mx-auto w-full max-w-4xl px-4">
|
||||
{!hasConversation && (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { toast } from '@/lib/toast'
|
|||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 ? (
|
||||
<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">
|
||||
{body}
|
||||
</Streamdown>
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -434,7 +434,7 @@ function ChatInputInner({
|
|||
}, [addFiles, isActive])
|
||||
|
||||
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 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
|
|
|
|||
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
1226
apps/x/apps/renderer/src/components/email-view.tsx
Normal file
File diff suppressed because it is too large
Load diff
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal file
778
apps/x/apps/renderer/src/components/meetings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx
Normal file
106
apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
Lightbulb,
|
||||
ListChecks,
|
||||
LoaderIcon,
|
||||
Mail,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
|
|
@ -99,7 +100,6 @@ import { toast } from "@/lib/toast"
|
|||
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
|
||||
import z from "zod"
|
||||
|
||||
interface TreeNode {
|
||||
|
|
@ -216,20 +216,19 @@ type SidebarContentPanelProps = {
|
|||
selectedBackgroundTask?: string | null
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
meetingState?: MeetingTranscriptionState
|
||||
meetingSummarizing?: boolean
|
||||
meetingAvailable?: boolean
|
||||
onToggleMeeting?: () => void
|
||||
isSearchOpen?: boolean
|
||||
isMeetingActionActive?: boolean
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
isSuggestedTopicsOpen?: boolean
|
||||
onOpenSuggestedTopics?: () => void
|
||||
isMeetingsOpen?: boolean
|
||||
onOpenMeetings?: () => void
|
||||
isLiveNotesOpen?: boolean
|
||||
onOpenLiveNotes?: () => void
|
||||
isBgTasksOpen?: boolean
|
||||
onOpenBgTasks?: () => void
|
||||
isEmailOpen?: boolean
|
||||
onOpenEmail?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -478,20 +477,19 @@ export function SidebarContentPanel({
|
|||
selectedBackgroundTask,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState = 'idle',
|
||||
meetingSummarizing = false,
|
||||
meetingAvailable = false,
|
||||
onToggleMeeting,
|
||||
isSearchOpen = false,
|
||||
isMeetingActionActive = false,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
isMeetingsOpen = false,
|
||||
onOpenMeetings,
|
||||
isLiveNotesOpen = false,
|
||||
onOpenLiveNotes,
|
||||
isBgTasksOpen = false,
|
||||
onOpenBgTasks,
|
||||
isEmailOpen = false,
|
||||
onOpenEmail,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -504,11 +502,12 @@ export function SidebarContentPanel({
|
|||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||
const { billing } = useBilling(isRowboatConnected)
|
||||
const isMeetingQuickActionSelected = isMeetingActionActive
|
||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
|
||||
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen
|
||||
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
|
||||
const isMeetingsQuickActionSelected = isMeetingsOpen && !isBrowserOpen
|
||||
const isLiveNotesQuickActionSelected = isLiveNotesOpen && !isBrowserOpen
|
||||
const isBgTasksQuickActionSelected = isBgTasksOpen && !isBrowserOpen
|
||||
const isEmailQuickActionSelected = isEmailOpen && !isBrowserOpen
|
||||
|
||||
const handleRowboatLogin = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -567,13 +566,13 @@ export function SidebarContentPanel({
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" {...props}>
|
||||
<Sidebar className="rowboat-sidebar border-r-0" {...props}>
|
||||
<SidebarHeader className="titlebar-drag-region">
|
||||
{/* Top spacer to clear the traffic lights + fixed toggle row */}
|
||||
<div className="h-8" />
|
||||
{/* Tab switcher - centered below the traffic lights row */}
|
||||
<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) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
|
|
@ -591,7 +590,7 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -617,41 +616,6 @@ export function SidebarContentPanel({
|
|||
<span>Search</span>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -697,6 +661,36 @@ export function SidebarContentPanel({
|
|||
<span>Background tasks</span>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1126,6 +1120,10 @@ function KnowledgeSection({
|
|||
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||
const visibleTree = React.useMemo(
|
||||
() => tree.filter((item) => item.path !== 'knowledge/Meetings'),
|
||||
[tree],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath) return
|
||||
|
|
@ -1154,7 +1152,7 @@ function KnowledgeSection({
|
|||
cancelled = true
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [selectedPath, expandedPaths, tree])
|
||||
}, [selectedPath, expandedPaths, visibleTree])
|
||||
|
||||
// Folder clicks highlight the folder; file clicks clear folder highlight
|
||||
const handleSelect = React.useCallback((path: string, kind: "file" | "dir") => {
|
||||
|
|
@ -1236,7 +1234,7 @@ function KnowledgeSection({
|
|||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||
<div ref={treeContainerRef}>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
{visibleTree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function TabBar<T>({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 self-stretch min-w-0',
|
||||
'rowboat-tabbar flex flex-1 self-stretch min-w-0',
|
||||
layout === 'scroll'
|
||||
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
: 'overflow-hidden'
|
||||
|
|
@ -57,7 +57,7 @@ export function TabBar<T>({
|
|||
type="button"
|
||||
onClick={() => onSwitchTab(tabId)}
|
||||
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]',
|
||||
isActive
|
||||
? 'bg-background text-foreground'
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ function isSamePath(a: string, b: string | undefined): boolean {
|
|||
* - Ticks every minute so callers using `formatRelativeTime` get a fresh label
|
||||
* without the underlying data changing.
|
||||
*
|
||||
* `notePath` may be either knowledge-relative (`Today.md`) or workspace-rooted
|
||||
* (`knowledge/Today.md`); the hook normalises internally.
|
||||
* `notePath` may be either knowledge-relative (`Digest.md`) or workspace-rooted
|
||||
* (`knowledge/Digest.md`); the hook normalises internally.
|
||||
*/
|
||||
export function useLiveNoteForPath(notePath: string | null | undefined): UseLiveNoteForPathResult {
|
||||
const knowledgeRelPath = stripKnowledgePrefix(notePath ?? null)
|
||||
|
|
|
|||
|
|
@ -2020,3 +2020,33 @@
|
|||
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- \`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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* 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
|
||||
* note edits) and the live-note run-agent prompt (so background runs use the
|
||||
* same rules without having to load the skill on every fire). One source of
|
||||
* truth, two consumers.
|
||||
* base. Imported by the `doc-collab` skill (so Copilot picks it up on note
|
||||
* edits), the live-note run-agent prompt, and the background-task run-agent
|
||||
* prompt (so background runs use the same rules without having to load the
|
||||
* skill on every fire).
|
||||
*
|
||||
* If you change this guide, restart the dev server / rebuild — both consumers
|
||||
* 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>\`.
|
||||
- **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\` (240–1600), \`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
|
||||
|
||||
- A **1-3 sentence opening summary** at the top of a complex note (a "lede") — concise enough to scan.
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ function ensureDefaultConfigs() {
|
|||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
|
||||
// Ensure default knowledge files exist
|
||||
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => {
|
||||
console.error('[DailyNote] Failed to ensure daily note:', err);
|
||||
// One-time deprecation for the old Today.md live dashboard. New installs no
|
||||
// longer get a generated Today.md; existing files are paused in place.
|
||||
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)
|
||||
|
|
|
|||
250
apps/x/packages/core/src/knowledge/classify_thread.ts
Normal file
250
apps/x/packages/core/src/knowledge/classify_thread.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
96
apps/x/packages/core/src/knowledge/deprecate_today_note.ts
Normal file
96
apps/x/packages/core/src/knowledge/deprecate_today_note.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
# 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\` (240–1600), \`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
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ async function publishCalendarSyncEvent(
|
|||
|
||||
// Configuration
|
||||
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 REQUIRED_SCOPES = [
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
|
|
|
|||
|
|
@ -8,19 +8,114 @@ import { GoogleClientFactory } from './google-client-factory.js';
|
|||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { createEvent } from '../events/producer.js';
|
||||
import { classifyThread, getUserEmail } from './classify_thread.js';
|
||||
|
||||
// Configuration
|
||||
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 MAX_THREADS_IN_DIGEST = 10;
|
||||
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 {
|
||||
threadId: 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 {
|
||||
const lines: string[] = [
|
||||
`# Gmail sync update`,
|
||||
|
|
@ -93,35 +188,416 @@ function decodeBase64(data: string): string {
|
|||
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 {
|
||||
let body = "";
|
||||
if (payload.parts) {
|
||||
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);
|
||||
// Simple quote stripping for MD
|
||||
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>'));
|
||||
body += cleanLines.join('\n');
|
||||
} else if (part.parts) {
|
||||
body += getBody(part);
|
||||
const { text, html } = extractBodyParts(payload);
|
||||
if (html) {
|
||||
const md = nhm.translate(html);
|
||||
return md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
}
|
||||
if (text) {
|
||||
return text.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||
}
|
||||
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) {
|
||||
const data = decodeBase64(payload.body.data);
|
||||
if (payload.mimeType === 'text/html') {
|
||||
const md = nhm.translate(data);
|
||||
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');
|
||||
if (part.parts) for (const sub of part.parts) walk(sub);
|
||||
};
|
||||
walk(payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -225,6 +701,14 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
|||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
||||
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 };
|
||||
|
||||
} 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 } {
|
||||
if (fs.existsSync(stateFile)) {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
|
|
@ -507,14 +1031,28 @@ async function performSync() {
|
|||
console.log("Authorization successful. Starting sync...");
|
||||
|
||||
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) {
|
||||
console.log("No history ID found, starting full sync...");
|
||||
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 {
|
||||
console.log("History ID found, starting partial sync...");
|
||||
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.");
|
||||
} catch (error) {
|
||||
console.error("Error during sync:", error);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import container from "../di/container.js";
|
|||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
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
|
||||
|
|
|
|||
|
|
@ -88,12 +88,13 @@ export type CalendarBlock = z.infer<typeof CalendarBlockSchema>;
|
|||
|
||||
export const EmailBlockSchema = z.object({
|
||||
threadId: z.string().optional(),
|
||||
threadUrl: z.string().url().optional(),
|
||||
summary: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
latest_email: z.string(),
|
||||
latest_email: z.string().optional(),
|
||||
past_summary: z.string().optional(),
|
||||
draft_response: z.string().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 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({
|
||||
title: z.string().optional(),
|
||||
emails: z.array(EmailBlockSchema),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { RowboatApiConfig } from './rowboat-account.js';
|
|||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -123,6 +124,38 @@ const ipcSchemas = {
|
|||
req: WorkspaceChangeEvent,
|
||||
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': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue