diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2b43983f..0596f1a5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -39,7 +39,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, voice } from '@x/core'; -import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; +import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; import { getBillingInfo } from '@x/core/dist/billing/billing.js'; /** @@ -705,6 +705,9 @@ export function setupIpcHandlers() { const schedule = await classifySchedule(args.instruction); return { schedule }; }, + 'inline-task:process': async (_event, args) => { + return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath); + }, 'voice:getConfig': async () => { return voice.getVoiceConfig(); }, diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 1ff56246..ebf8a650 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -46,6 +46,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.8.0", "sonner": "^2.0.7", "streamdown": "^1.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a92f2d28..b37b8559 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3869,6 +3869,7 @@ function App() { > { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} onPrimaryHeadingCommit={() => { untitledRenameReadyPathsRef.current.add(tab.path) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 9f4a2d2a..590b6585 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -9,6 +9,10 @@ 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 { ImageBlockExtension } from '@/extensions/image-block' +import { EmbedBlockExtension } from '@/extensions/embed-block' +import { ChartBlockExtension } from '@/extensions/chart-block' +import { TableBlockExtension } from '@/extensions/table-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -136,6 +140,14 @@ 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 === 'imageBlock') { + blocks.push('```image\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'embedBlock') { + blocks.push('```embed\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'chartBlock') { + blocks.push('```chart\n' + (node.attrs?.data as string || '{}') + '\n```') + } else if (node.type === 'tableBlock') { + blocks.push('```table\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```') @@ -220,6 +232,7 @@ interface MarkdownEditorProps { frontmatter?: string | null onFrontmatterChange?: (raw: string | null) => void onExport?: (format: 'md' | 'pdf' | 'docx') => void + notePath?: string } type WikiLinkMatch = { @@ -311,6 +324,7 @@ export function MarkdownEditor({ frontmatter, onFrontmatterChange, onExport, + notePath, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -429,6 +443,10 @@ export function MarkdownEditor({ }), ImageUploadPlaceholderExtension, TaskBlockExtension, + ImageBlockExtension, + EmbedBlockExtension, + ChartBlockExtension, + TableBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -912,24 +930,17 @@ export function MarkdownEditor({ } 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) - } + // Insert a temporary processing block + const blockData: Record = { instruction, processing: true } + + const insertFrom = activeRowboatMention.range.from + const insertTo = activeRowboatMention.range.to editor .chain() .focus() .insertContentAt( - { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + { from: insertFrom, to: insertTo }, [ { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, { type: 'paragraph' }, @@ -937,17 +948,124 @@ export function MarkdownEditor({ ) .run() - // Mark note as live - if (onFrontmatterChange) { - const fields = extractAllFrontmatterValues(frontmatter ?? null) - fields['live_note'] = 'true' - onFrontmatterChange(buildFrontmatter(fields)) - } - setActiveRowboatMention(null) setRowboatAnchorTop(null) + + // Get editor content for the agent + const editorContent = editor.storage.markdown?.getMarkdown?.() ?? '' + + // Helper to find the processing block + const findProcessingBlock = (): number | null => { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.type.name === 'taskBlock') { + try { + const data = JSON.parse(node.attrs.data || '{}') + if (data.instruction === instruction && data.processing === true) { + pos = p + return false + } + } catch { /* skip */ } + } + }) + return pos + } + + try { + // Call the copilot assistant for both one-time and recurring tasks + const result = await window.ipc.invoke('inline-task:process', { + instruction, + noteContent: editorContent, + notePath: notePath ?? '', + }) + + const currentPos = findProcessingBlock() + if (currentPos === null) return + + const node = editor.state.doc.nodeAt(currentPos) + if (!node) return + + if (result.schedule) { + // Recurring/scheduled task: update block with schedule, write target tags to disk + const targetId = Math.random().toString(36).slice(2, 10) + const updatedData: Record = { + instruction: result.instruction, + schedule: result.schedule, + 'schedule-label': result.scheduleLabel, + targetId, + } + const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, { + data: JSON.stringify(updatedData), + }) + editor.view.dispatch(tr) + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + // Write target tags directly to the file on disk after a short delay + // to let the editor save the updated content first + if (notePath) { + setTimeout(async () => { + try { + const file = await window.ipc.invoke('workspace:readFile', { path: notePath }) + const content = file.data + const openTag = `` + const closeTag = `` + + // Only add if not already present + if (content.includes(openTag)) return + + // Find the task block in the raw markdown and insert target tags after it + const blockJson = JSON.stringify(updatedData) + const blockStart = content.indexOf('```task\n' + blockJson) + if (blockStart !== -1) { + const blockEnd = content.indexOf('\n```', blockStart + 8) + if (blockEnd !== -1) { + const insertAt = blockEnd + 4 // after the closing ``` + const before = content.slice(0, insertAt) + const after = content.slice(insertAt) + const updated = before + '\n\n' + openTag + '\n' + closeTag + after + await window.ipc.invoke('workspace:writeFile', { + path: notePath, + data: updated, + opts: { encoding: 'utf8' }, + }) + } + } + } catch (err) { + console.error('[RowboatAdd] Failed to write target tags:', err) + } + }, 500) + } + } else { + // One-time task: remove the processing block, insert response in its place + const insertPos = currentPos + const deleteEnd = currentPos + node.nodeSize + editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run() + + if (result.response) { + editor.chain().insertContentAt(insertPos, result.response).run() + } + } + } catch (error) { + console.error('[RowboatAdd] Processing failed:', error) + + // Remove the processing block on error + const currentPos = findProcessingBlock() + if (currentPos !== null) { + const node = editor.state.doc.nodeAt(currentPos) + if (node) { + editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run() + } + } + } } - }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath]) const handleRowboatRemove = useCallback(() => { if (!editor || !rowboatBlockEdit) return diff --git a/apps/x/apps/renderer/src/extensions/chart-block.tsx b/apps/x/apps/renderer/src/extensions/chart-block.tsx new file mode 100644 index 00000000..3377b157 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/chart-block.tsx @@ -0,0 +1,173 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, BarChart3 } from 'lucide-react' +import { blocks } from '@x/shared' +import { useState, useEffect } from 'react' +import { + LineChart, Line, + BarChart, Bar, + PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts' + +const CHART_COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#0088fe', '#00c49f'] + +function ChartBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.ChartBlock | null = null + + try { + config = blocks.ChartBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + const [fileData, setFileData] = useState[] | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!config?.source) return + setLoading(true) + setError(null) + ;(window as unknown as { ipc: { invoke: (channel: string, args: Record) => Promise } }) + .ipc.invoke('workspace:readFile', { path: config.source, encoding: 'utf-8' }) + .then((content: string) => { + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) { + setFileData(parsed) + } else { + setError('Source file must contain a JSON array') + } + }) + .catch((err: Error) => { + setError(err.message || 'Failed to load data file') + }) + .finally(() => setLoading(false)) + }, [config?.source]) + + if (!config) { + return ( + +
+ + Invalid chart block +
+
+ ) + } + + const data = config.data || fileData + + const renderChart = () => { + if (loading) return
Loading data...
+ if (error) return
{error}
+ if (!data || data.length === 0) return
No data
+ + return ( + + {config!.chart === 'line' ? ( + + + + + + + + + ) : config!.chart === 'bar' ? ( + + + + + + + + + ) : ( + + + + + {data.map((_, index) => ( + + ))} + + + )} + + ) + } + + return ( + +
+ + {config.title &&
{config.title}
} + {renderChart()} +
+
+ ) +} + +export const ChartBlockExtension = Node.create({ + name: 'chartBlock', + group: 'block', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: '{}', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'pre', + priority: 60, + getAttrs(element) { + const code = element.querySelector('code') + if (!code) return false + const cls = code.className || '' + if (cls.includes('language-chart')) { + return { data: code.textContent || '{}' } + } + return false + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chart-block' })] + }, + + addNodeView() { + return ReactNodeViewRenderer(ChartBlockView) + }, + + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) { + state.write('```chart\n' + node.attrs.data + '\n```') + state.closeBlock(node) + }, + parse: { + // handled by parseHTML + }, + }, + } + }, +}) diff --git a/apps/x/apps/renderer/src/extensions/embed-block.tsx b/apps/x/apps/renderer/src/extensions/embed-block.tsx new file mode 100644 index 00000000..b3bc6969 --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/embed-block.tsx @@ -0,0 +1,143 @@ +import { mergeAttributes, Node } from '@tiptap/react' +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react' +import { X, ExternalLink } from 'lucide-react' +import { blocks } from '@x/shared' + +function getEmbedUrl(provider: string, url: string): string | null { + if (provider === 'youtube') { + // Handle youtube.com/watch?v=X and youtu.be/X + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/) + if (match) return `https://www.youtube.com/embed/${match[1]}` + } + if (provider === 'figma') { + // Convert www.figma.com/design/:key/... → embed.figma.com/design/:key?embed-host=rowboat + const figmaMatch = url.match(/figma\.com\/(design|board|proto)\/([\w-]+)/) + if (figmaMatch) { + return `https://embed.figma.com/${figmaMatch[1]}/${figmaMatch[2]}?embed-host=rowboat` + } + // Legacy /file/ URLs + const legacyMatch = url.match(/figma\.com\/file\/([\w-]+)/) + if (legacyMatch) { + return `https://embed.figma.com/design/${legacyMatch[1]}?embed-host=rowboat` + } + } + return null +} + +function EmbedBlockView({ node, deleteNode }: { node: { attrs: Record }; deleteNode: () => void }) { + const raw = node.attrs.data as string + let config: blocks.EmbedBlock | null = null + + try { + config = blocks.EmbedBlockSchema.parse(JSON.parse(raw)) + } catch { + // fallback below + } + + if (!config) { + return ( + +
+ + Invalid embed block +
+
+ ) + } + + const embedUrl = getEmbedUrl(config.provider, config.url) + + return ( + +
+ + {embedUrl ? ( +
+