diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 4d272275..2de2b437 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -32,6 +32,7 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo. import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; import { versionHistory } from '@x/core'; +import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -531,5 +532,10 @@ export function setupIpcHandlers() { 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); }, + // Inline task schedule classification + 'inline-task:classifySchedule': async (_event, args) => { + const schedule = await classifySchedule(args.instruction); + return { schedule }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index b3868bc5..08160a23 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -19,6 +19,7 @@ import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js" import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; +import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -178,6 +179,9 @@ app.whenReady().then(async () => { // start note tagging service initNoteTagging(); + // start inline task service (@rowboat: mentions) + initInlineTasks(); + // start background agent runner (scheduled agents) initAgentRunner(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a0cd396..768ff02b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1017,10 +1017,13 @@ function App() { frontmatterByPathRef.current.set(pathToLoad, fm) const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() const isSameEditorFile = editorPathRef.current === pathToLoad - const wouldClobberActiveEdits = - isSameEditorFile - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body) - if (!wouldClobberActiveEdits) { + const knownBaseline = initialContentByPathRef.current.get(pathToLoad) + const hasKnownBaseline = knownBaseline !== undefined + const hasUnsavedEdits = + hasKnownBaseline + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) + const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits + if (!shouldPreserveActiveDraft) { setEditorContent(body) if (pathToLoad.endsWith('.md')) { setEditorCacheForPath(pathToLoad, body) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 7858d2df..09212793 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' +import { TaskBlockExtension } from '@/extensions/task-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { }) }) blocks.push(listLines.join('\n')) + } else if (node.type === 'taskBlock') { + blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -181,8 +184,21 @@ import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' +import { RowboatMentionPopover } from './rowboat-mention-popover' import '@/styles/editor.css' +type RowboatMentionMatch = { + range: { from: number; to: number } +} + +type RowboatBlockEdit = { + /** ProseMirror position of the taskBlock node */ + nodePos: number + /** Existing instruction text */ + existingText: string +} + type WikiLinkConfig = { files: string[] recent: string[] @@ -304,6 +320,10 @@ export function MarkdownEditor({ const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) + const [activeRowboatMention, setActiveRowboatMention] = useState(null) + const [rowboatBlockEdit, setRowboatBlockEdit] = useState(null) + const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null) + const rowboatBlockEditRef = useRef(null) // Keep ref in sync with state for the plugin to access selectionHighlightRef.current = selectionHighlight @@ -399,6 +419,7 @@ export function MarkdownEditor({ }, }), ImageUploadPlaceholderExtension, + TaskBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -492,7 +513,7 @@ export function MarkdownEditor({ return false }, - handleClickOn: (_view, _pos, node, _nodePos, event) => { + handleClickOn: (_view, _pos, node, nodePos, event) => { if (node.type.name === 'wikiLink') { event.preventDefault() wikiLinks?.onOpen?.(node.attrs.path) @@ -575,6 +596,55 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + const updateRowboatMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const { $from } = selection + if ($from.parent.type.spec.code) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Match @rowboat at a word boundary (preceded by nothing or whitespace) + const match = textBefore.match(/(^|\s)@rowboat$/) + if (!match) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const triggerStart = textBefore.length - '@rowboat'.length + const from = selection.from - (textBefore.length - triggerStart) + const to = selection.from + setActiveRowboatMention({ range: { from, to } }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setRowboatAnchorTop(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor]) + useEffect(() => { if (!editor || !wikiLinks) return editor.on('update', updateWikiLinkState) @@ -585,6 +655,32 @@ export function MarkdownEditor({ } }, [editor, wikiLinks, updateWikiLinkState]) + useEffect(() => { + if (!editor) return + editor.on('update', updateRowboatMentionState) + editor.on('selectionUpdate', updateRowboatMentionState) + return () => { + editor.off('update', updateRowboatMentionState) + editor.off('selectionUpdate', updateRowboatMentionState) + } + }, [editor, updateRowboatMentionState]) + + // When a tell-rowboat block is clicked, compute anchor and open popover + useEffect(() => { + if (!rowboatBlockEdit || !editor) return + const wrapper = wrapperRef.current + if (!wrapper) return + const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor, rowboatBlockEdit]) + // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { @@ -675,6 +771,85 @@ export function MarkdownEditor({ handleSelectWikiLinkRef.current = handleSelectWikiLink }, [handleSelectWikiLink]) + const handleRowboatAdd = useCallback(async (instruction: string) => { + if (!editor) return + + if (rowboatBlockEdit) { + // Editing existing taskBlock — update its data attribute + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node && node.type.name === 'taskBlock') { + // Preserve existing schedule data + let updated: Record = { instruction } + try { + const existing = JSON.parse(node.attrs.data || '{}') + updated = { ...existing, instruction } + } catch { + // Invalid JSON — just write new + } + const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) }) + editor.view.dispatch(tr) + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + return + } + + if (activeRowboatMention) { + // Classify schedule intent for new blocks + const blockData: Record = { instruction } + try { + const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) + if (result.schedule) { + const { label, ...rest } = result.schedule + blockData.schedule = rest + blockData['schedule-label'] = label + } + } catch (error) { + console.error('[RowboatAdd] Schedule classification failed:', error) + } + + editor + .chain() + .focus() + .insertContentAt( + { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + [ + { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, + { type: 'paragraph' }, + ], + ) + .run() + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + } + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + + const handleRowboatRemove = useCallback(() => { + if (!editor || !rowboatBlockEdit) return + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node) { + editor + .chain() + .focus() + .deleteRange({ from: nodePos, to: nodePos + node.nodeSize }) + .run() + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }, [editor, rowboatBlockEdit]) + const handleScroll = useCallback(() => { updateWikiLinkState() }, [updateWikiLinkState]) @@ -789,6 +964,19 @@ export function MarkdownEditor({ ) : null} + { + setActiveRowboatMention(null) + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }} + /> ) diff --git a/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx new file mode 100644 index 00000000..a5a63bc7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx @@ -0,0 +1,109 @@ +import { useState, useRef, useEffect } from 'react' +import { Loader2 } from 'lucide-react' + +interface RowboatMentionPopoverProps { + open: boolean + anchor: { top: number; left: number; width: number } | null + initialText?: string + onAdd: (instruction: string) => void | Promise + onRemove?: () => void + onClose: () => void +} + +export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const textareaRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (open) { + setText(initialText) + setLoading(false) + requestAnimationFrame(() => { + textareaRef.current?.focus() + }) + } + }, [open, initialText]) + + // Close on outside click + useEffect(() => { + if (!open) return + const handleMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener('mousedown', handleMouseDown) + return () => document.removeEventListener('mousedown', handleMouseDown) + }, [open, onClose]) + + if (!open || !anchor) return null + + const handleSubmit = async () => { + const trimmed = text.trim() + if (!trimmed || loading) return + setLoading(true) + try { + await onAdd(trimmed) + } finally { + setLoading(false) + } + setText('') + } + + return ( +
+
+
+ @rowboat +