Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev

This commit is contained in:
tusharmagar 2026-03-19 11:10:39 +05:30
commit 758a2779f4
18 changed files with 1388 additions and 39 deletions

View file

@ -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();
},

View file

@ -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",

View file

@ -3869,6 +3869,7 @@ function App() {
>
<MarkdownEditor
content={tabContent}
notePath={tab.path}
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
onPrimaryHeadingCommit={() => {
untitledRenameReadyPathsRef.current.add(tab.path)

View file

@ -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<HTMLDivElement>(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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
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 = `<!--task-target:${targetId}-->`
const closeTag = `<!--/task-target:${targetId}-->`
// 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

View file

@ -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<string, unknown> }; 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<Record<string, unknown>[] | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!config?.source) return
setLoading(true)
setError(null)
;(window as unknown as { ipc: { invoke: (channel: string, args: Record<string, string>) => Promise<string> } })
.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 (
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
<div className="chart-block-card chart-block-error">
<BarChart3 size={16} />
<span>Invalid chart block</span>
</div>
</NodeViewWrapper>
)
}
const data = config.data || fileData
const renderChart = () => {
if (loading) return <div className="chart-block-loading">Loading data...</div>
if (error) return <div className="chart-block-error-msg">{error}</div>
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
return (
<ResponsiveContainer width="100%" height={250}>
{config!.chart === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={config!.x} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
</LineChart>
) : config!.chart === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={config!.x} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey={config!.y} fill="#8884d8" />
</BarChart>
) : (
<PieChart>
<Tooltip />
<Legend />
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
</PieChart>
)}
</ResponsiveContainer>
)
}
return (
<NodeViewWrapper className="chart-block-wrapper" data-type="chart-block">
<div className="chart-block-card">
<button
className="chart-block-delete"
onClick={deleteNode}
aria-label="Delete chart block"
>
<X size={14} />
</button>
{config.title && <div className="chart-block-title">{config.title}</div>}
{renderChart()}
</div>
</NodeViewWrapper>
)
}
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<string, unknown> }) {
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
},
},
}
},
})

View file

@ -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<string, unknown> }; 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 (
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
<div className="embed-block-card embed-block-error">
<ExternalLink size={16} />
<span>Invalid embed block</span>
</div>
</NodeViewWrapper>
)
}
const embedUrl = getEmbedUrl(config.provider, config.url)
return (
<NodeViewWrapper className="embed-block-wrapper" data-type="embed-block">
<div className="embed-block-card">
<button
className="embed-block-delete"
onClick={deleteNode}
aria-label="Delete embed block"
>
<X size={14} />
</button>
{embedUrl ? (
<div className="embed-block-iframe-container">
<iframe
src={embedUrl}
className="embed-block-iframe"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-popups"
/>
</div>
) : (
<a
href={config.url}
target="_blank"
rel="noopener noreferrer"
className="embed-block-link"
>
<ExternalLink size={14} />
{config.url}
</a>
)}
{config.caption && (
<div className="embed-block-caption">{config.caption}</div>
)}
</div>
</NodeViewWrapper>
)
}
export const EmbedBlockExtension = Node.create({
name: 'embedBlock',
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-embed')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'embed-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmbedBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```embed\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,104 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, ImageIcon } from 'lucide-react'
import { blocks } from '@x/shared'
function ImageBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.ImageBlock | null = null
try {
config = blocks.ImageBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
<div className="image-block-card image-block-error">
<ImageIcon size={16} />
<span>Invalid image block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="image-block-wrapper" data-type="image-block">
<div className="image-block-card">
<button
className="image-block-delete"
onClick={deleteNode}
aria-label="Delete image block"
>
<X size={14} />
</button>
<img
src={config.src}
alt={config.alt || ''}
className="image-block-img"
/>
{config.caption && (
<div className="image-block-caption">{config.caption}</div>
)}
</div>
</NodeViewWrapper>
)
}
export const ImageBlockExtension = Node.create({
name: 'imageBlock',
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-image')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(ImageBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```image\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Table2 } from 'lucide-react'
import { blocks } from '@x/shared'
function TableBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.TableBlock | null = null
try {
config = blocks.TableBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
<div className="table-block-card table-block-error">
<Table2 size={16} />
<span>Invalid table block</span>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="table-block-wrapper" data-type="table-block">
<div className="table-block-card">
<button
className="table-block-delete"
onClick={deleteNode}
aria-label="Delete table block"
>
<X size={14} />
</button>
{config.title && <div className="table-block-title">{config.title}</div>}
<div className="table-block-scroll">
<table className="table-block-table">
<thead>
<tr>
{config.columns.map((col) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, i) => (
<tr key={i}>
{config!.columns.map((col) => (
<td key={col}>{String(row[col] ?? '')}</td>
))}
</tr>
))}
{config.data.length === 0 && (
<tr>
<td colSpan={config.columns.length} className="table-block-empty">
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</NodeViewWrapper>
)
}
export const TableBlockExtension = Node.create({
name: 'tableBlock',
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-table')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'table-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TableBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```table\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -1,22 +1,39 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { CalendarClock, X } from 'lucide-react'
import { CalendarClock, Loader2, X } from 'lucide-react'
import { inlineTask } from '@x/shared'
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let instruction = ''
let scheduleLabel = ''
let processing = false
let lastRunAt = ''
try {
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
instruction = parsed.instruction
scheduleLabel = parsed['schedule-label'] ?? ''
processing = parsed.processing ?? false
lastRunAt = parsed.lastRunAt ?? ''
} catch {
// Fallback: show raw data
instruction = raw
}
const lastRunLabel = lastRunAt ? formatDateTime(lastRunAt) : ''
return (
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
<div className="task-block-card">
@ -29,10 +46,17 @@ function TaskBlockView({ node, deleteNode }: { node: { attrs: Record<string, unk
</button>
<div className="task-block-content">
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
{scheduleLabel && (
{processing && (
<span className="task-block-schedule">
<Loader2 size={12} className="animate-spin" />
processing
</span>
)}
{!processing && scheduleLabel && (
<span className="task-block-schedule">
<CalendarClock size={12} />
{scheduleLabel}
{lastRunLabel && <span className="task-block-last-run"> · last ran {lastRunLabel}</span>}
</span>
)}
</div>

View file

@ -608,6 +608,234 @@
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
.tiptap-editor .ProseMirror .task-block-last-run {
color: color-mix(in srgb, var(--foreground) 38%, transparent);
}
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,
.tiptap-editor .ProseMirror .chart-block-wrapper,
.tiptap-editor .ProseMirror .table-block-wrapper {
margin: 8px 0;
}
.tiptap-editor .ProseMirror .image-block-card,
.tiptap-editor .ProseMirror .embed-block-card,
.tiptap-editor .ProseMirror .chart-block-card,
.tiptap-editor .ProseMirror .table-block-card {
position: relative;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
cursor: default;
transition: background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .image-block-card:hover,
.tiptap-editor .ProseMirror .embed-block-card:hover,
.tiptap-editor .ProseMirror .chart-block-card:hover,
.tiptap-editor .ProseMirror .table-block-card:hover {
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.tiptap-editor .ProseMirror .image-block-delete,
.tiptap-editor .ProseMirror .embed-block-delete,
.tiptap-editor .ProseMirror .chart-block-delete,
.tiptap-editor .ProseMirror .table-block-delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete {
opacity: 1;
}
.tiptap-editor .ProseMirror .image-block-delete:hover,
.tiptap-editor .ProseMirror .embed-block-delete:hover,
.tiptap-editor .ProseMirror .chart-block-delete:hover,
.tiptap-editor .ProseMirror .table-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Image block */
.tiptap-editor .ProseMirror .image-block-img {
max-width: 100%;
border-radius: 4px;
}
.tiptap-editor .ProseMirror .image-block-caption {
margin-top: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
text-align: center;
}
.tiptap-editor .ProseMirror .image-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
/* Embed block */
.tiptap-editor .ProseMirror .embed-block-iframe-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
border-radius: 4px;
overflow: hidden;
}
.tiptap-editor .ProseMirror .embed-block-iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.tiptap-editor .ProseMirror .embed-block-link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
font-size: 13px;
color: var(--primary);
text-decoration: none;
word-break: break-all;
}
.tiptap-editor .ProseMirror .embed-block-caption {
margin-top: 6px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
text-align: center;
}
.tiptap-editor .ProseMirror .embed-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
/* Chart block */
.tiptap-editor .ProseMirror .chart-block-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .chart-block-loading,
.tiptap-editor .ProseMirror .chart-block-empty {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .chart-block-error-msg {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
font-size: 13px;
color: #ef4444;
}
.tiptap-editor .ProseMirror .chart-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
/* Table block */
.tiptap-editor .ProseMirror .table-block-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .table-block-scroll {
overflow-x: auto;
}
.tiptap-editor .ProseMirror .table-block-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tiptap-editor .ProseMirror .table-block-table th {
text-align: left;
padding: 6px 10px;
border-bottom: 2px solid var(--border);
font-weight: 600;
color: var(--foreground);
white-space: nowrap;
}
.tiptap-editor .ProseMirror .table-block-table td {
padding: 5px 10px;
border-bottom: 1px solid var(--border);
color: color-mix(in srgb, var(--foreground) 80%, transparent);
}
.tiptap-editor .ProseMirror .table-block-table tr:last-child td {
border-bottom: none;
}
.tiptap-editor .ProseMirror .table-block-empty {
text-align: center;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
padding: 16px 10px !important;
}
.tiptap-editor .ProseMirror .table-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
/* Dark mode overrides */
.dark .tiptap-editor .ProseMirror {
color: rgba(255, 255, 255, 0.9);